From f70c2e3c40e00c611fac2c37d47ce36f8d8b6176 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:36:25 +0200 Subject: [PATCH 01/76] fix(ci): replace inline python interpolation in version workflows (#123) Use jq + bash regex for package.json parsing and semver validation, stop persisting the checkout credential, and pass step outputs via env instead of interpolating them into the shell. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- .github/workflows/meta-check.yml | 6 +++--- .github/workflows/version-bump.yml | 30 ++++++++++++++++-------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/meta-check.yml b/.github/workflows/meta-check.yml index e7ff0224..d6313198 100644 --- a/.github/workflows/meta-check.yml +++ b/.github/workflows/meta-check.yml @@ -84,12 +84,12 @@ jobs: - uses: actions/checkout@v4 - name: Check package.json is valid JSON - run: python3 -c "import json; json.load(open('package.json'))" + run: jq empty package.json - name: Check version format (semver) run: | - VERSION=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") - if echo "$VERSION" | grep -qP '^\d+\.\d+\.\d+$'; then + VERSION=$(jq -er '.version | select(type == "string")' package.json) + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Version: $VERSION" else echo "::error::Invalid version format: $VERSION (expected semver X.Y.Z)" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index fb080dea..b651db92 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -22,28 +22,30 @@ jobs: - uses: actions/checkout@v4 with: ref: main - token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - name: Bump patch version in package.json id: bump run: | - CURRENT=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") + CURRENT=$(jq -er '.version | select(type == "string")' package.json) + if [[ ! "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format: $CURRENT (expected semver X.Y.Z)" + exit 1 + fi IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" NEW_PATCH=$((PATCH + 1)) NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" - python3 -c " - import json - with open('package.json', 'r') as f: - data = json.load(f) - data['version'] = '${NEW_VERSION}' - with open('package.json', 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - " + TEMP_FILE=$(mktemp) + jq --arg version "$NEW_VERSION" '.version = $version' package.json > "$TEMP_FILE" + mv "$TEMP_FILE" package.json echo "old=$CURRENT" >> "$GITHUB_OUTPUT" echo "new=$NEW_VERSION" >> "$GITHUB_OUTPUT" - name: Commit version bump + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OLD_VERSION: ${{ steps.bump.outputs.old }} + NEW_VERSION: ${{ steps.bump.outputs.new }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -51,7 +53,7 @@ jobs: if git diff --cached --quiet; then echo "No version change" else - git commit -m "Bump version to ${{ steps.bump.outputs.new }} [skip ci]" - git push - echo "Bumped: ${{ steps.bump.outputs.old }} → ${{ steps.bump.outputs.new }}" + git commit -m "Bump version to ${NEW_VERSION} [skip ci]" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main + echo "Bumped: ${OLD_VERSION} → ${NEW_VERSION}" fi From 8aac825bb9c6192e7d1a413fb728b30541b14c39 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:36:34 +0200 Subject: [PATCH 02/76] fix(ci): stop auto-committing built native binaries (#125) Drop the contents:write publish job from build-native.yml, default the workflow to read-only permissions, and pin the actions to verified SHAs. Native artifacts are now uploaded only; a maintainer commits them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- .github/workflows/build-native.yml | 51 ++++-------------------------- 1 file changed, 7 insertions(+), 44 deletions(-) diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index e594643e..9efe7bfb 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -1,5 +1,10 @@ name: Build Native Libraries +# This workflow deliberately has no repository write permission. Native artifacts +# must be reviewed and committed by a maintainer rather than published by CI. +permissions: + contents: read + on: push: paths: @@ -33,7 +38,7 @@ jobs: name: Build (${{ matrix.os }}) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Configure CMake run: cmake -S 'Native~' -B build ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release @@ -42,50 +47,8 @@ jobs: run: cmake --build build --config Release - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: native-${{ matrix.os }} path: ${{ matrix.plugin_dir }}/${{ matrix.artifact }} if-no-files-found: error - - commit: - needs: build - runs-on: ubuntu-latest - if: github.event_name == 'push' - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Copy binaries to plugin directories - run: | - # Windows - mkdir -p Plugins/x86_64 - cp artifacts/native-windows-latest/xatlas-unity.dll Plugins/x86_64/ - - # Linux - cp artifacts/native-ubuntu-latest/libxatlas-unity.so Plugins/x86_64/ - - # macOS (universal binary: x86_64 + arm64) - mkdir -p Plugins/macOS - cp artifacts/native-macos-latest/libxatlas-unity.dylib Plugins/macOS/ - - - name: Commit and push - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Plugins/ - if git diff --cached --quiet; then - echo "No changes to native libraries" - else - git commit -m "Build native libraries (auto) [skip ci]" - git push - fi From ab237f5d598f6bb2fd10e35ae36ac201a8a5e193 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:38:38 +0200 Subject: [PATCH 03/76] fix(tools): harden gen.bat argument forwarding (#135) Forwarding %* let cmd.exe re-parse metacharacters from the original command line. Whitelist the documented flag/positional forms and quote each argument explicitly instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Tools~/gen.bat | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/Tools~/gen.bat b/Tools~/gen.bat index 05dadc57..144e4cca 100644 --- a/Tools~/gen.bat +++ b/Tools~/gen.bat @@ -9,10 +9,46 @@ rem Example: rem Tools\gen.bat "_results~/noSymSplit_2026-04-28" --gallery-id "noSymSplit_2026-04-28" setlocal -set SCRIPT=%~dp0build_gallery.py +set "SCRIPT=%~dp0build_gallery.py" if not exist "%SCRIPT%" ( - echo [gen.bat] Cannot find %SCRIPT% + echo [gen.bat] Cannot find "%SCRIPT%" exit /b 1 ) -python "%SCRIPT%" %* + +rem Forward only the argument forms supported by build_gallery.py. Expanding +rem %%* here would cause cmd.exe to parse metacharacters in the original +rem command line a second time. +if "%~2"=="" goto run_basic +if "%~4"=="" goto run_one_option +if not "%~6"=="" goto usage +if /i "%~2"=="--out" if /i "%~4"=="--gallery-id" ( + python "%SCRIPT%" "%~1" --out "%~3" --gallery-id "%~5" + goto end +) +if /i "%~2"=="--gallery-id" if /i "%~4"=="--out" ( + python "%SCRIPT%" "%~1" --gallery-id "%~3" --out "%~5" + goto end +) +goto usage + +:run_one_option +if /i "%~2"=="--out" ( + python "%SCRIPT%" "%~1" --out "%~3" + goto end +) +if /i "%~2"=="--gallery-id" ( + python "%SCRIPT%" "%~1" --gallery-id "%~3" + goto end +) +goto usage + +:run_basic +python "%SCRIPT%" "%~1" +goto end + +:usage +echo [gen.bat] Usage: gen.bat "data-folder" [--out "output-folder"] [--gallery-id "id"] +exit /b 2 + +:end endlocal From 9d2f97f748348ee224c25a4f9b358dc7dcbd003c Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:38:43 +0200 Subject: [PATCH 04/76] fix(docs): shell-escape skills-overhaul parameter file (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 wrote raw package.json values into /tmp/skills-overhaul.env, which Phase 3 later sources — a command-substitution injection chain. Emit the file with `declare -p` so values cannot be reinterpreted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- .claude/skills/skills-overhaul-plan.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.claude/skills/skills-overhaul-plan.md b/.claude/skills/skills-overhaul-plan.md index 30af1978..cf8455b1 100644 --- a/.claude/skills/skills-overhaul-plan.md +++ b/.claude/skills/skills-overhaul-plan.md @@ -522,19 +522,15 @@ if [ -n "$NAMESPACE" ] && ! echo "$NAMESPACE" | grep -qE '^SashaRX\.'; then echo "WARN: namespace '$NAMESPACE' does not match SashaRX. — migration required in Phase 2." fi -# 0.7 Write the parameter file used by later phases -cat > /tmp/skills-overhaul.env < /tmp/skills-overhaul.env cat /tmp/skills-overhaul.env ``` -**Verification:** `/tmp/skills-overhaul.env` is non-empty; working tree either clean or known-dirty; git tag created. +**Verification:** `/tmp/skills-overhaul.env` is non-empty and contains only `declare -- NAME=...` records; working tree either clean or known-dirty; git tag created. **Rollback:** `git reset --hard ` (SHA saved in `/tmp/pre-overhaul-sha.txt`), and `git tag -d pre-skills-overhaul-`. @@ -895,4 +891,4 @@ The overhaul is successful when, in each of the three repos: 1. **Run Phase 0 in each of the three repos and paste back the `/tmp/skills-overhaul.env` output for each.** This resolves the repo-identity question (are `UnityLodUvLightmapTransfer` / `lightmap-uv-tool` the same as `UnityMeshLab`?) and gives the plan the parameter values it needs. Estimated time: 3 minutes per repo. 2. **Decide the two policy questions in §5.2**: (a) namespace Stance A vs B and (b) whether to include directive "ALWAYS invoke" phrasing on the three most-critical skills. These decisions change only a handful of lines in `_shared/naming-conventions.md` and in three frontmatter descriptions. -3. **Execute Phase 1 (scaffold) in one repo as a pilot**, then Phase 2.1 (`_shared/` and `_checklists/` files) — these are repo-agnostic and can be copied identically to the other two repos once validated. Commit per the sub-commit plan. Only after that pilot runs cleanly through Phase 4 validation do you replicate to the remaining repos. \ No newline at end of file +3. **Execute Phase 1 (scaffold) in one repo as a pilot**, then Phase 2.1 (`_shared/` and `_checklists/` files) — these are repo-agnostic and can be copied identically to the other two repos once validated. Commit per the sub-commit plan. Only after that pilot runs cleanly through Phase 4 validation do you replicate to the remaining repos. From 254e1314b1abde2d744442514ba64b490390a48b Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:44:45 +0200 Subject: [PATCH 05/76] fix(packaging): mirror .gitignore exclusions into .npmignore (#164) npm ignores .gitignore entirely once .npmignore exists, so local Unity, IDE and build-intermediate artifacts could be published. Add the missing patterns; no tracked file is affected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- .npmignore | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.npmignore b/.npmignore index 91429044..2a527151 100644 --- a/.npmignore +++ b/.npmignore @@ -18,3 +18,39 @@ CONFLICT_CHECK_LAST_5_PRS.md CONFLICT_CHECK_LAST_5_PRS.md.meta .gitignore *.bat + +# Local Unity-generated files (mirrors .gitignore because npm stops using it +# whenever this file exists) +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Bb]uild/ +[Bb]uilds/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +*.csproj +*.sln +*.suo +*.user +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Local OS and IDE files +.DS_Store +Thumbs.db +.idea/ +.vs/ +*.swp + +# Build intermediates and temporary files +*.obj +*.lib +*.exp +test.txt +.commitmsg +commitmsg.txt From 7e05c119b43cbef605324faac60fb56da9da08a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:29:18 +0000 Subject: [PATCH 06/76] fix(native): vendor xatlas instead of fetching a moving branch (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchContent pulled jpcy/xatlas at GIT_TAG master, so every native build compiled whatever HEAD happened to be. Vendor a reviewed snapshot under Native~/third_party/xatlas and point CMake at it; meshoptimizer keeps its pinned tag. The vendored xatlas.cpp/xatlas.h are byte-identical to upstream jpcy/xatlas f700c77. The PR's copy carried ~34 extra lines (an unreferenced s_preserveChartScale flag plus "SashaRX.UnityMeshLab fork" comments) that are called from nowhere in this repo; those were stripped so the snapshot stays a verbatim upstream copy. The prebuilt binaries under Plugins/ are intentionally left at their current versions — they must be rebuilt from these vendored sources via the build-native workflow and committed by a maintainer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Native~/CMakeLists.txt | 18 +- Native~/third_party/xatlas/xatlas.cpp | 10044 ++++++++++++++++++++++++ Native~/third_party/xatlas/xatlas.h | 269 + README.md | 2 +- 4 files changed, 10322 insertions(+), 11 deletions(-) create mode 100644 Native~/third_party/xatlas/xatlas.cpp create mode 100644 Native~/third_party/xatlas/xatlas.h diff --git a/Native~/CMakeLists.txt b/Native~/CMakeLists.txt index c039a707..fb07f386 100644 --- a/Native~/CMakeLists.txt +++ b/Native~/CMakeLists.txt @@ -4,14 +4,8 @@ project(xatlas-unity-bridge LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -# ── Fetch xatlas source ── +# ── Dependencies ── include(FetchContent) -FetchContent_Declare( - xatlas - GIT_REPOSITORY https://github.com/jpcy/xatlas.git - GIT_TAG master - GIT_SHALLOW TRUE -) FetchContent_Declare( meshoptimizer GIT_REPOSITORY https://github.com/zeux/meshoptimizer.git @@ -21,17 +15,21 @@ FetchContent_Declare( set(MESHOPT_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) # meshoptimizer static lib must be PIC when linked into our shared lib (Linux/GCC) set(CMAKE_POSITION_INDEPENDENT_CODE ON) -FetchContent_MakeAvailable(xatlas meshoptimizer) +FetchContent_MakeAvailable(meshoptimizer) + +# Keep xatlas vendored so native builds never execute or compile code from a +# moving upstream branch. Update this reviewed snapshot explicitly when needed. +set(XATLAS_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/xatlas") # ── Build shared library ── add_library(xatlas-unity SHARED xatlas-unity-bridge.cpp src/collision.cpp - ${xatlas_SOURCE_DIR}/source/xatlas/xatlas.cpp + ${XATLAS_SOURCE_DIR}/xatlas.cpp ) target_include_directories(xatlas-unity PRIVATE - ${xatlas_SOURCE_DIR}/source/xatlas + ${XATLAS_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/third_party ) diff --git a/Native~/third_party/xatlas/xatlas.cpp b/Native~/third_party/xatlas/xatlas.cpp new file mode 100644 index 00000000..5c5c57ec --- /dev/null +++ b/Native~/third_party/xatlas/xatlas.cpp @@ -0,0 +1,10044 @@ +/* +MIT License + +Copyright (c) 2018-2020 Jonathan Young + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +/* +thekla_atlas +https://github.com/Thekla/thekla_atlas +MIT License +Copyright (c) 2013 Thekla, Inc +Copyright NVIDIA Corporation 2006 -- Ignacio Castano + +Fast-BVH +https://github.com/brandonpelfrey/Fast-BVH +MIT License +Copyright (c) 2012 Brandon Pelfrey +*/ +#include "xatlas.h" +#ifndef XATLAS_C_API +#define XATLAS_C_API 0 +#endif +#if XATLAS_C_API +#include "xatlas_c.h" +#endif +#include +#include +#include +#include +#include +#include // FLT_MAX +#include +#include +#define __STDC_LIMIT_MACROS +#include +#include +#include + +#ifndef XA_DEBUG +#ifdef NDEBUG +#define XA_DEBUG 0 +#else +#define XA_DEBUG 1 +#endif +#endif + +#ifndef XA_PROFILE +#define XA_PROFILE 0 +#endif +#if XA_PROFILE +#include +#endif + +#ifndef XA_MULTITHREADED +#define XA_MULTITHREADED 1 +#endif + +#define XA_STR(x) #x +#define XA_XSTR(x) XA_STR(x) + +#ifndef XA_ASSERT +#define XA_ASSERT(exp) if (!(exp)) { XA_PRINT_WARNING("\rASSERT: %s %s %d\n", XA_XSTR(exp), __FILE__, __LINE__); } +#endif + +#ifndef XA_DEBUG_ASSERT +#define XA_DEBUG_ASSERT(exp) assert(exp) +#endif + +#ifndef XA_PRINT +#define XA_PRINT(...) \ + if (xatlas::internal::s_print && xatlas::internal::s_printVerbose) \ + xatlas::internal::s_print(__VA_ARGS__); +#endif + +#ifndef XA_PRINT_WARNING +#define XA_PRINT_WARNING(...) \ + if (xatlas::internal::s_print) \ + xatlas::internal::s_print(__VA_ARGS__); +#endif + +#define XA_ALLOC(tag, type) (type *)internal::Realloc(nullptr, sizeof(type), tag, __FILE__, __LINE__) +#define XA_ALLOC_ARRAY(tag, type, num) (type *)internal::Realloc(nullptr, sizeof(type) * (num), tag, __FILE__, __LINE__) +#define XA_REALLOC(tag, ptr, type, num) (type *)internal::Realloc(ptr, sizeof(type) * (num), tag, __FILE__, __LINE__) +#define XA_REALLOC_SIZE(tag, ptr, size) (uint8_t *)internal::Realloc(ptr, size, tag, __FILE__, __LINE__) +#define XA_FREE(ptr) internal::Realloc(ptr, 0, internal::MemTag::Default, __FILE__, __LINE__) +#define XA_NEW(tag, type) new (XA_ALLOC(tag, type)) type() +#define XA_NEW_ARGS(tag, type, ...) new (XA_ALLOC(tag, type)) type(__VA_ARGS__) + +#ifdef _MSC_VER +#define XA_INLINE __forceinline +#else +#define XA_INLINE inline +#endif + +#if defined(__clang__) || defined(__GNUC__) +#define XA_NODISCARD [[nodiscard]] +#elif defined(_MSC_VER) +#define XA_NODISCARD _Check_return_ +#else +#define XA_NODISCARD +#endif + +#define XA_UNUSED(a) ((void)(a)) + +#define XA_MERGE_CHARTS 1 +#define XA_MERGE_CHARTS_MIN_NORMAL_DEVIATION 0.5f +#define XA_RECOMPUTE_CHARTS 1 +#define XA_CHECK_PARAM_WINDING 0 +#define XA_CHECK_PIECEWISE_CHART_QUALITY 0 +#define XA_CHECK_T_JUNCTIONS 0 + +#define XA_DEBUG_HEAP 0 +#define XA_DEBUG_SINGLE_CHART 0 +#define XA_DEBUG_ALL_CHARTS_INVALID 0 +#define XA_DEBUG_EXPORT_ATLAS_IMAGES 0 +#define XA_DEBUG_EXPORT_ATLAS_IMAGES_PER_CHART 0 // Export an atlas image after each chart is added. +#define XA_DEBUG_EXPORT_BOUNDARY_GRID 0 +#define XA_DEBUG_EXPORT_TGA (XA_DEBUG_EXPORT_ATLAS_IMAGES || XA_DEBUG_EXPORT_BOUNDARY_GRID) +#define XA_DEBUG_EXPORT_OBJ_FACE_GROUPS 0 +#define XA_DEBUG_EXPORT_OBJ_CHART_GROUPS 0 +#define XA_DEBUG_EXPORT_OBJ_PLANAR_REGIONS 0 +#define XA_DEBUG_EXPORT_OBJ_CHARTS 0 +#define XA_DEBUG_EXPORT_OBJ_TJUNCTION 0 // XA_CHECK_T_JUNCTIONS must also be set +#define XA_DEBUG_EXPORT_OBJ_CHARTS_AFTER_PARAMETERIZATION 0 +#define XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION 0 +#define XA_DEBUG_EXPORT_OBJ_RECOMPUTED_CHARTS 0 + +#define XA_DEBUG_EXPORT_OBJ (0 \ + || XA_DEBUG_EXPORT_OBJ_FACE_GROUPS \ + || XA_DEBUG_EXPORT_OBJ_CHART_GROUPS \ + || XA_DEBUG_EXPORT_OBJ_PLANAR_REGIONS \ + || XA_DEBUG_EXPORT_OBJ_CHARTS \ + || XA_DEBUG_EXPORT_OBJ_TJUNCTION \ + || XA_DEBUG_EXPORT_OBJ_CHARTS_AFTER_PARAMETERIZATION \ + || XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION \ + || XA_DEBUG_EXPORT_OBJ_RECOMPUTED_CHARTS) + +#ifdef _MSC_VER +#define XA_FOPEN(_file, _filename, _mode) { if (fopen_s(&_file, _filename, _mode) != 0) _file = NULL; } +#define XA_SPRINTF(_buffer, _size, _format, ...) sprintf_s(_buffer, _size, _format, __VA_ARGS__) +#else +#define XA_FOPEN(_file, _filename, _mode) _file = fopen(_filename, _mode) +#define XA_SPRINTF(_buffer, _size, _format, ...) sprintf(_buffer, _format, __VA_ARGS__) +#endif + +namespace xatlas { +namespace internal { + +static ReallocFunc s_realloc = realloc; +static FreeFunc s_free = free; +static PrintFunc s_print = printf; +static bool s_printVerbose = false; + +#if XA_PROFILE +typedef uint64_t Duration; + +#define XA_PROFILE_START(var) const std::chrono::time_point var##Start = std::chrono::high_resolution_clock::now(); +#define XA_PROFILE_END(var) internal::s_profile.var += uint64_t(std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - var##Start).count()); +#define XA_PROFILE_PRINT_AND_RESET(label, var) XA_PRINT("%s%.2f seconds (%g ms)\n", label, internal::durationToSeconds(internal::s_profile.var), internal::durationToMs(internal::s_profile.var)); internal::s_profile.var = 0u; +#define XA_PROFILE_ALLOC 0 + +struct ProfileData +{ +#if XA_PROFILE_ALLOC + std::atomic alloc; +#endif + std::chrono::time_point addMeshRealStart; + Duration addMeshReal; + Duration addMeshCopyData; + std::atomic addMeshThread; + std::atomic addMeshCreateColocals; + Duration computeChartsReal; + std::atomic computeChartsThread; + std::atomic createFaceGroups; + std::atomic extractInvalidMeshGeometry; + std::atomic chartGroupComputeChartsReal; + std::atomic chartGroupComputeChartsThread; + std::atomic createChartGroupMesh; + std::atomic createChartGroupMeshColocals; + std::atomic createChartGroupMeshBoundaries; + std::atomic buildAtlas; + std::atomic buildAtlasInit; + std::atomic planarCharts; + std::atomic originalUvCharts; + std::atomic clusteredCharts; + std::atomic clusteredChartsPlaceSeeds; + std::atomic clusteredChartsPlaceSeedsBoundaryIntersection; + std::atomic clusteredChartsRelocateSeeds; + std::atomic clusteredChartsReset; + std::atomic clusteredChartsGrow; + std::atomic clusteredChartsGrowBoundaryIntersection; + std::atomic clusteredChartsMerge; + std::atomic clusteredChartsFillHoles; + std::atomic copyChartFaces; + std::atomic createChartMeshAndParameterizeReal; + std::atomic createChartMeshAndParameterizeThread; + std::atomic createChartMesh; + std::atomic parameterizeCharts; + std::atomic parameterizeChartsOrthogonal; + std::atomic parameterizeChartsLSCM; + std::atomic parameterizeChartsRecompute; + std::atomic parameterizeChartsPiecewise; + std::atomic parameterizeChartsPiecewiseBoundaryIntersection; + std::atomic parameterizeChartsEvaluateQuality; + Duration packCharts; + Duration packChartsAddCharts; + std::atomic packChartsAddChartsThread; + std::atomic packChartsAddChartsRestoreTexcoords; + Duration packChartsRasterize; + Duration packChartsDilate; + Duration packChartsFindLocation; + Duration packChartsBlit; + Duration buildOutputMeshes; +}; + +static ProfileData s_profile; + +static double durationToMs(Duration c) +{ + return (double)c * 0.001; +} + +static double durationToSeconds(Duration c) +{ + return (double)c * 0.000001; +} +#else +#define XA_PROFILE_START(var) +#define XA_PROFILE_END(var) +#define XA_PROFILE_PRINT_AND_RESET(label, var) +#define XA_PROFILE_ALLOC 0 +#endif + +struct MemTag +{ + enum + { + Default, + BitImage, + BVH, + Matrix, + Mesh, + MeshBoundaries, + MeshColocals, + MeshEdgeMap, + MeshIndices, + MeshNormals, + MeshPositions, + MeshTexcoords, + OpenNL, + SegmentAtlasChartCandidates, + SegmentAtlasChartFaces, + SegmentAtlasMeshData, + SegmentAtlasPlanarRegions, + Count + }; +}; + +#if XA_DEBUG_HEAP +struct AllocHeader +{ + size_t size; + const char *file; + int line; + int tag; + uint32_t id; + AllocHeader *prev, *next; + bool free; +}; + +static std::mutex s_allocMutex; +static AllocHeader *s_allocRoot = nullptr; +static size_t s_allocTotalCount = 0, s_allocTotalSize = 0, s_allocPeakSize = 0, s_allocCount[MemTag::Count] = { 0 }, s_allocTotalTagSize[MemTag::Count] = { 0 }, s_allocPeakTagSize[MemTag::Count] = { 0 }; +static uint32_t s_allocId =0 ; +static constexpr uint32_t kAllocRedzone = 0x12345678; + +static void *Realloc(void *ptr, size_t size, int tag, const char *file, int line) +{ + std::unique_lock lock(s_allocMutex); + if (!size && !ptr) + return nullptr; + uint8_t *realPtr = nullptr; + AllocHeader *header = nullptr; + if (ptr) { + realPtr = ((uint8_t *)ptr) - sizeof(AllocHeader); + header = (AllocHeader *)realPtr; + } + if (realPtr && size) { + s_allocTotalSize -= header->size; + s_allocTotalTagSize[header->tag] -= header->size; + // realloc, remove. + if (header->prev) + header->prev->next = header->next; + else + s_allocRoot = header->next; + if (header->next) + header->next->prev = header->prev; + } + if (!size) { + s_allocTotalSize -= header->size; + s_allocTotalTagSize[header->tag] -= header->size; + XA_ASSERT(!header->free); // double free + header->free = true; + return nullptr; + } + size += sizeof(AllocHeader) + sizeof(kAllocRedzone); + uint8_t *newPtr = (uint8_t *)s_realloc(realPtr, size); + if (!newPtr) + return nullptr; + header = (AllocHeader *)newPtr; + header->size = size; + header->file = file; + header->line = line; + header->tag = tag; + header->id = s_allocId++; + header->free = false; + if (!s_allocRoot) { + s_allocRoot = header; + header->prev = header->next = 0; + } else { + header->prev = nullptr; + header->next = s_allocRoot; + s_allocRoot = header; + header->next->prev = header; + } + s_allocTotalCount++; + s_allocTotalSize += size; + if (s_allocTotalSize > s_allocPeakSize) + s_allocPeakSize = s_allocTotalSize; + s_allocCount[tag]++; + s_allocTotalTagSize[tag] += size; + if (s_allocTotalTagSize[tag] > s_allocPeakTagSize[tag]) + s_allocPeakTagSize[tag] = s_allocTotalTagSize[tag]; + auto redzone = (uint32_t *)(newPtr + size - sizeof(kAllocRedzone)); + *redzone = kAllocRedzone; + return newPtr + sizeof(AllocHeader); +} + +static void ReportLeaks() +{ + printf("Checking for memory leaks...\n"); + bool anyLeaks = false; + AllocHeader *header = s_allocRoot; + while (header) { + if (!header->free) { + printf(" Leak: ID %u, %zu bytes, %s %d\n", header->id, header->size, header->file, header->line); + anyLeaks = true; + } + auto redzone = (const uint32_t *)((const uint8_t *)header + header->size - sizeof(kAllocRedzone)); + if (*redzone != kAllocRedzone) + printf(" Redzone corrupted: %zu bytes %s %d\n", header->size, header->file, header->line); + header = header->next; + } + if (!anyLeaks) + printf(" No memory leaks\n"); + header = s_allocRoot; + while (header) { + AllocHeader *destroy = header; + header = header->next; + s_realloc(destroy, 0); + } + s_allocRoot = nullptr; + s_allocTotalSize = s_allocPeakSize = 0; + for (int i = 0; i < MemTag::Count; i++) + s_allocTotalTagSize[i] = s_allocPeakTagSize[i] = 0; +} + +static void PrintMemoryUsage() +{ + XA_PRINT("Total allocations: %zu\n", s_allocTotalCount); + XA_PRINT("Memory usage: %0.2fMB current, %0.2fMB peak\n", internal::s_allocTotalSize / 1024.0f / 1024.0f, internal::s_allocPeakSize / 1024.0f / 1024.0f); + static const char *labels[] = { // Sync with MemTag + "Default", + "BitImage", + "BVH", + "Matrix", + "Mesh", + "MeshBoundaries", + "MeshColocals", + "MeshEdgeMap", + "MeshIndices", + "MeshNormals", + "MeshPositions", + "MeshTexcoords", + "OpenNL", + "SegmentAtlasChartCandidates", + "SegmentAtlasChartFaces", + "SegmentAtlasMeshData", + "SegmentAtlasPlanarRegions" + }; + for (int i = 0; i < MemTag::Count; i++) { + XA_PRINT(" %s: %zu allocations, %0.2fMB current, %0.2fMB peak\n", labels[i], internal::s_allocCount[i], internal::s_allocTotalTagSize[i] / 1024.0f / 1024.0f, internal::s_allocPeakTagSize[i] / 1024.0f / 1024.0f); + } +} + +#define XA_PRINT_MEM_USAGE internal::PrintMemoryUsage(); +#else +static void *Realloc(void *ptr, size_t size, int /*tag*/, const char * /*file*/, int /*line*/) +{ + if (size == 0 && !ptr) + return nullptr; + if (size == 0 && s_free) { + s_free(ptr); + return nullptr; + } +#if XA_PROFILE_ALLOC + XA_PROFILE_START(alloc) +#endif + void *mem = s_realloc(ptr, size); +#if XA_PROFILE_ALLOC + XA_PROFILE_END(alloc) +#endif + XA_DEBUG_ASSERT(size <= 0 || (size > 0 && mem)); + return mem; +} +#define XA_PRINT_MEM_USAGE +#endif + +static constexpr float kPi = 3.14159265358979323846f; +static constexpr float kPi2 = 6.28318530717958647692f; +static constexpr float kEpsilon = 0.0001f; +static constexpr float kAreaEpsilon = FLT_EPSILON; +static constexpr float kNormalEpsilon = 0.001f; + +static int align(int x, int a) +{ + return (x + a - 1) & ~(a - 1); +} + +template +static T max(const T &a, const T &b) +{ + return a > b ? a : b; +} + +template +static T min(const T &a, const T &b) +{ + return a < b ? a : b; +} + +template +static T max3(const T &a, const T &b, const T &c) +{ + return max(a, max(b, c)); +} + +/// Return the maximum of the three arguments. +template +static T min3(const T &a, const T &b, const T &c) +{ + return min(a, min(b, c)); +} + +/// Clamp between two values. +template +static T clamp(const T &x, const T &a, const T &b) +{ + return min(max(x, a), b); +} + +template +static void swap(T &a, T &b) +{ + T temp = a; + a = b; + b = temp; +} + +union FloatUint32 +{ + float f; + uint32_t u; +}; + +static bool isFinite(float f) +{ + FloatUint32 fu; + fu.f = f; + return fu.u != 0x7F800000u && fu.u != 0x7F800001u; +} + +static bool isNan(float f) +{ + return f != f; +} + +// Robust floating point comparisons: +// http://realtimecollisiondetection.net/blog/?p=89 +static bool equal(const float f0, const float f1, const float epsilon) +{ + //return fabs(f0-f1) <= epsilon; + return fabs(f0 - f1) <= epsilon * max3(1.0f, fabsf(f0), fabsf(f1)); +} + +static int ftoi_ceil(float val) +{ + return (int)ceilf(val); +} + +static bool isZero(const float f, const float epsilon) +{ + return fabs(f) <= epsilon; +} + +static float square(float f) +{ + return f * f; +} + +/** Return the next power of two. +* @see http://graphics.stanford.edu/~seander/bithacks.html +* @warning Behaviour for 0 is undefined. +* @note isPowerOfTwo(x) == true -> nextPowerOfTwo(x) == x +* @note nextPowerOfTwo(x) = 2 << log2(x-1) +*/ +static uint32_t nextPowerOfTwo(uint32_t x) +{ + XA_DEBUG_ASSERT( x != 0 ); + // On modern CPUs this is supposed to be as fast as using the bsr instruction. + x--; + x |= x >> 1; + x |= x >> 2; + x |= x >> 4; + x |= x >> 8; + x |= x >> 16; + return x + 1; +} + +class Vector2 +{ +public: + Vector2() {} + explicit Vector2(float f) : x(f), y(f) {} + Vector2(float _x, float _y): x(_x), y(_y) {} + + Vector2 operator-() const + { + return Vector2(-x, -y); + } + + void operator+=(const Vector2 &v) + { + x += v.x; + y += v.y; + } + + void operator-=(const Vector2 &v) + { + x -= v.x; + y -= v.y; + } + + void operator*=(float s) + { + x *= s; + y *= s; + } + + void operator*=(const Vector2 &v) + { + x *= v.x; + y *= v.y; + } + + float x, y; +}; + +static bool operator==(const Vector2 &a, const Vector2 &b) +{ + return a.x == b.x && a.y == b.y; +} + +static bool operator!=(const Vector2 &a, const Vector2 &b) +{ + return a.x != b.x || a.y != b.y; +} + +/*static Vector2 operator+(const Vector2 &a, const Vector2 &b) +{ + return Vector2(a.x + b.x, a.y + b.y); +}*/ + +static Vector2 operator-(const Vector2 &a, const Vector2 &b) +{ + return Vector2(a.x - b.x, a.y - b.y); +} + +static Vector2 operator*(const Vector2 &v, float s) +{ + return Vector2(v.x * s, v.y * s); +} + +static float dot(const Vector2 &a, const Vector2 &b) +{ + return a.x * b.x + a.y * b.y; +} + +static float lengthSquared(const Vector2 &v) +{ + return v.x * v.x + v.y * v.y; +} + +static float length(const Vector2 &v) +{ + return sqrtf(lengthSquared(v)); +} + +#if XA_DEBUG +static bool isNormalized(const Vector2 &v, float epsilon = kNormalEpsilon) +{ + return equal(length(v), 1, epsilon); +} +#endif + +static Vector2 normalize(const Vector2 &v) +{ + const float l = length(v); + XA_DEBUG_ASSERT(l > 0.0f); // Never negative. + const Vector2 n = v * (1.0f / l); + XA_DEBUG_ASSERT(isNormalized(n)); + return n; +} + +static Vector2 normalizeSafe(const Vector2 &v, const Vector2 &fallback) +{ + const float l = length(v); + if (l > 0.0f) // Never negative. + return v * (1.0f / l); + return fallback; +} + +static bool equal(const Vector2 &v1, const Vector2 &v2, float epsilon) +{ + return equal(v1.x, v2.x, epsilon) && equal(v1.y, v2.y, epsilon); +} + +static Vector2 min(const Vector2 &a, const Vector2 &b) +{ + return Vector2(min(a.x, b.x), min(a.y, b.y)); +} + +static Vector2 max(const Vector2 &a, const Vector2 &b) +{ + return Vector2(max(a.x, b.x), max(a.y, b.y)); +} + +static bool isFinite(const Vector2 &v) +{ + return isFinite(v.x) && isFinite(v.y); +} + +static float triangleArea(const Vector2 &a, const Vector2 &b, const Vector2 &c) +{ + // IC: While it may be appealing to use the following expression: + //return (c.x * a.y + a.x * b.y + b.x * c.y - b.x * a.y - c.x * b.y - a.x * c.y) * 0.5f; + // That's actually a terrible idea. Small triangles far from the origin can end up producing fairly large floating point + // numbers and the results becomes very unstable and dependent on the order of the factors. + // Instead, it's preferable to subtract the vertices first, and multiply the resulting small values together. The result + // in this case is always much more accurate (as long as the triangle is small) and less dependent of the location of + // the triangle. + //return ((a.x - c.x) * (b.y - c.y) - (a.y - c.y) * (b.x - c.x)) * 0.5f; + const Vector2 v0 = a - c; + const Vector2 v1 = b - c; + return (v0.x * v1.y - v0.y * v1.x) * 0.5f; +} + +static bool linesIntersect(const Vector2 &a1, const Vector2 &a2, const Vector2 &b1, const Vector2 &b2, float epsilon) +{ + const Vector2 v0 = a2 - a1; + const Vector2 v1 = b2 - b1; + const float denom = -v1.x * v0.y + v0.x * v1.y; + if (equal(denom, 0.0f, epsilon)) + return false; + const float s = (-v0.y * (a1.x - b1.x) + v0.x * (a1.y - b1.y)) / denom; + if (s > epsilon && s < 1.0f - epsilon) { + const float t = ( v1.x * (a1.y - b1.y) - v1.y * (a1.x - b1.x)) / denom; + return t > epsilon && t < 1.0f - epsilon; + } + return false; +} + +struct Vector2i +{ + Vector2i() {} + Vector2i(int32_t _x, int32_t _y) : x(_x), y(_y) {} + + int32_t x, y; +}; + +class Vector3 +{ +public: + Vector3() {} + explicit Vector3(float f) : x(f), y(f), z(f) {} + Vector3(float _x, float _y, float _z) : x(_x), y(_y), z(_z) {} + Vector3(const Vector2 &v, float _z) : x(v.x), y(v.y), z(_z) {} + + Vector2 xy() const + { + return Vector2(x, y); + } + + Vector3 operator-() const + { + return Vector3(-x, -y, -z); + } + + void operator+=(const Vector3 &v) + { + x += v.x; + y += v.y; + z += v.z; + } + + void operator-=(const Vector3 &v) + { + x -= v.x; + y -= v.y; + z -= v.z; + } + + void operator*=(float s) + { + x *= s; + y *= s; + z *= s; + } + + void operator/=(float s) + { + float is = 1.0f / s; + x *= is; + y *= is; + z *= is; + } + + void operator*=(const Vector3 &v) + { + x *= v.x; + y *= v.y; + z *= v.z; + } + + void operator/=(const Vector3 &v) + { + x /= v.x; + y /= v.y; + z /= v.z; + } + + float x, y, z; +}; + +static Vector3 operator+(const Vector3 &a, const Vector3 &b) +{ + return Vector3(a.x + b.x, a.y + b.y, a.z + b.z); +} + +static Vector3 operator-(const Vector3 &a, const Vector3 &b) +{ + return Vector3(a.x - b.x, a.y - b.y, a.z - b.z); +} + +static bool operator==(const Vector3 &a, const Vector3 &b) +{ + return a.x == b.x && a.y == b.y && a.z == b.z; +} + +static Vector3 cross(const Vector3 &a, const Vector3 &b) +{ + return Vector3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x); +} + +static Vector3 operator*(const Vector3 &v, float s) +{ + return Vector3(v.x * s, v.y * s, v.z * s); +} + +static Vector3 operator/(const Vector3 &v, float s) +{ + return v * (1.0f / s); +} + +static float dot(const Vector3 &a, const Vector3 &b) +{ + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +static float lengthSquared(const Vector3 &v) +{ + return v.x * v.x + v.y * v.y + v.z * v.z; +} + +static float length(const Vector3 &v) +{ + return sqrtf(lengthSquared(v)); +} + +static bool isNormalized(const Vector3 &v, float epsilon = kNormalEpsilon) +{ + return equal(length(v), 1.0f, epsilon); +} + +static Vector3 normalize(const Vector3 &v) +{ + const float l = length(v); + XA_DEBUG_ASSERT(l > 0.0f); // Never negative. + const Vector3 n = v * (1.0f / l); + XA_DEBUG_ASSERT(isNormalized(n)); + return n; +} + +static Vector3 normalizeSafe(const Vector3 &v, const Vector3 &fallback) +{ + const float l = length(v); + if (l > 0.0f) // Never negative. + return v * (1.0f / l); + return fallback; +} + +static bool equal(const Vector3 &v0, const Vector3 &v1, float epsilon) +{ + return fabs(v0.x - v1.x) <= epsilon && fabs(v0.y - v1.y) <= epsilon && fabs(v0.z - v1.z) <= epsilon; +} + +static Vector3 min(const Vector3 &a, const Vector3 &b) +{ + return Vector3(min(a.x, b.x), min(a.y, b.y), min(a.z, b.z)); +} + +static Vector3 max(const Vector3 &a, const Vector3 &b) +{ + return Vector3(max(a.x, b.x), max(a.y, b.y), max(a.z, b.z)); +} + +#if XA_DEBUG +bool isFinite(const Vector3 &v) +{ + return isFinite(v.x) && isFinite(v.y) && isFinite(v.z); +} +#endif + +struct Extents2 +{ + Vector2 min, max; + + Extents2() {} + + Extents2(Vector2 p1, Vector2 p2) + { + min = xatlas::internal::min(p1, p2); + max = xatlas::internal::max(p1, p2); + } + + void reset() + { + min.x = min.y = FLT_MAX; + max.x = max.y = -FLT_MAX; + } + + void add(Vector2 p) + { + min = xatlas::internal::min(min, p); + max = xatlas::internal::max(max, p); + } + + Vector2 midpoint() const + { + return Vector2(min.x + (max.x - min.x) * 0.5f, min.y + (max.y - min.y) * 0.5f); + } + + static bool intersect(const Extents2 &e1, const Extents2 &e2) + { + return e1.min.x <= e2.max.x && e1.max.x >= e2.min.x && e1.min.y <= e2.max.y && e1.max.y >= e2.min.y; + } +}; + +// From Fast-BVH +struct AABB +{ + AABB() : min(FLT_MAX, FLT_MAX, FLT_MAX), max(-FLT_MAX, -FLT_MAX, -FLT_MAX) {} + AABB(const Vector3 &_min, const Vector3 &_max) : min(_min), max(_max) { } + AABB(const Vector3 &p, float radius = 0.0f) : min(p), max(p) { if (radius > 0.0f) expand(radius); } + + bool intersect(const AABB &other) const + { + return min.x <= other.max.x && max.x >= other.min.x && min.y <= other.max.y && max.y >= other.min.y && min.z <= other.max.z && max.z >= other.min.z; + } + + void expandToInclude(const Vector3 &p) + { + min = internal::min(min, p); + max = internal::max(max, p); + } + + void expandToInclude(const AABB &aabb) + { + min = internal::min(min, aabb.min); + max = internal::max(max, aabb.max); + } + + void expand(float amount) + { + min -= Vector3(amount); + max += Vector3(amount); + } + + Vector3 centroid() const + { + return min + (max - min) * 0.5f; + } + + uint32_t maxDimension() const + { + const Vector3 extent = max - min; + uint32_t result = 0; + if (extent.y > extent.x) { + result = 1; + if (extent.z > extent.y) + result = 2; + } + else if(extent.z > extent.x) + result = 2; + return result; + } + + Vector3 min, max; +}; + +struct ArrayBase +{ + ArrayBase(uint32_t _elementSize, int memTag = MemTag::Default) : buffer(nullptr), elementSize(_elementSize), size(0), capacity(0) + { +#if XA_DEBUG_HEAP + this->memTag = memTag; +#else + XA_UNUSED(memTag); +#endif + } + + ~ArrayBase() + { + XA_FREE(buffer); + } + + XA_INLINE void clear() + { + size = 0; + } + + void copyFrom(const uint8_t *data, uint32_t length) + { + XA_DEBUG_ASSERT(data); + XA_DEBUG_ASSERT(length > 0); + resize(length, true); + if (buffer && data && length > 0) + memcpy(buffer, data, length * elementSize); + } + + void copyTo(ArrayBase &other) const + { + XA_DEBUG_ASSERT(elementSize == other.elementSize); + XA_DEBUG_ASSERT(size > 0); + other.resize(size, true); + if (other.buffer && buffer && size > 0) + memcpy(other.buffer, buffer, size * elementSize); + } + + void destroy() + { + size = 0; + XA_FREE(buffer); + buffer = nullptr; + capacity = 0; + size = 0; + } + + // Insert the given element at the given index shifting all the elements up. + void insertAt(uint32_t index, const uint8_t *value) + { + XA_DEBUG_ASSERT(index >= 0 && index <= size); + XA_DEBUG_ASSERT(value); + resize(size + 1, false); + XA_DEBUG_ASSERT(buffer); + if (buffer && index < size - 1) + memmove(buffer + elementSize * (index + 1), buffer + elementSize * index, elementSize * (size - 1 - index)); + if (buffer && value) + memcpy(&buffer[index * elementSize], value, elementSize); + } + + void moveTo(ArrayBase &other) + { + XA_DEBUG_ASSERT(elementSize == other.elementSize); + other.destroy(); + other.buffer = buffer; + other.elementSize = elementSize; + other.size = size; + other.capacity = capacity; +#if XA_DEBUG_HEAP + other.memTag = memTag; +#endif + buffer = nullptr; + elementSize = size = capacity = 0; + } + + void pop_back() + { + XA_DEBUG_ASSERT(size > 0); + resize(size - 1, false); + } + + void push_back(const uint8_t *value) + { + XA_DEBUG_ASSERT(value < buffer || value >= buffer + size); + XA_DEBUG_ASSERT(value); + resize(size + 1, false); + XA_DEBUG_ASSERT(buffer); + if (buffer && value) + memcpy(&buffer[(size - 1) * elementSize], value, elementSize); + } + + void push_back(const ArrayBase &other) + { + XA_DEBUG_ASSERT(elementSize == other.elementSize); + if (other.size > 0) { + const uint32_t oldSize = size; + resize(size + other.size, false); + XA_DEBUG_ASSERT(buffer); + if (buffer) + memcpy(buffer + oldSize * elementSize, other.buffer, other.size * other.elementSize); + } + } + + // Remove the element at the given index. This is an expensive operation! + void removeAt(uint32_t index) + { + XA_DEBUG_ASSERT(index >= 0 && index < size); + XA_DEBUG_ASSERT(buffer); + if (buffer) { + if (size > 1) + memmove(buffer + elementSize * index, buffer + elementSize * (index + 1), elementSize * (size - 1 - index)); + if (size > 0) + size--; + } + } + + // Element at index is swapped with the last element, then the array length is decremented. + void removeAtFast(uint32_t index) + { + XA_DEBUG_ASSERT(index >= 0 && index < size); + XA_DEBUG_ASSERT(buffer); + if (buffer) { + if (size > 1 && index != size - 1) + memcpy(buffer + elementSize * index, buffer + elementSize * (size - 1), elementSize); + if (size > 0) + size--; + } + } + + void reserve(uint32_t desiredSize) + { + if (desiredSize > capacity) + setArrayCapacity(desiredSize); + } + + void resize(uint32_t newSize, bool exact) + { + size = newSize; + if (size > capacity) { + // First allocation is always exact. Otherwise, following allocations grow array to 150% of desired size. + uint32_t newBufferSize; + if (capacity == 0 || exact) + newBufferSize = size; + else + newBufferSize = size + (size >> 2); + setArrayCapacity(newBufferSize); + } + } + + void setArrayCapacity(uint32_t newCapacity) + { + XA_DEBUG_ASSERT(newCapacity >= size); + if (newCapacity == 0) { + // free the buffer. + if (buffer != nullptr) { + XA_FREE(buffer); + buffer = nullptr; + } + } else { + // realloc the buffer +#if XA_DEBUG_HEAP + buffer = XA_REALLOC_SIZE(memTag, buffer, newCapacity * elementSize); +#else + buffer = XA_REALLOC_SIZE(MemTag::Default, buffer, newCapacity * elementSize); +#endif + } + capacity = newCapacity; + } + +#if XA_DEBUG_HEAP + void setMemTag(int _memTag) + { + this->memTag = _memTag; + } +#endif + + uint8_t *buffer; + uint32_t elementSize; + uint32_t size; + uint32_t capacity; +#if XA_DEBUG_HEAP + int memTag; +#endif +}; + +template +class Array +{ +public: + Array(int memTag = MemTag::Default) : m_base(sizeof(T), memTag) {} + Array(const Array&) = delete; + Array &operator=(const Array &) = delete; + + XA_INLINE const T &operator[](uint32_t index) const + { + XA_DEBUG_ASSERT(index < m_base.size); + XA_DEBUG_ASSERT(m_base.buffer); + return ((const T *)m_base.buffer)[index]; + } + + XA_INLINE T &operator[](uint32_t index) + { + XA_DEBUG_ASSERT(index < m_base.size); + XA_DEBUG_ASSERT(m_base.buffer); + return ((T *)m_base.buffer)[index]; + } + + XA_INLINE const T &back() const + { + XA_DEBUG_ASSERT(!isEmpty()); + return ((const T *)m_base.buffer)[m_base.size - 1]; + } + + XA_INLINE T *begin() { return (T *)m_base.buffer; } + XA_INLINE void clear() { m_base.clear(); } + + bool contains(const T &value) const + { + for (uint32_t i = 0; i < m_base.size; i++) { + if (((const T *)m_base.buffer)[i] == value) + return true; + } + return false; + } + + void copyFrom(const T *data, uint32_t length) { m_base.copyFrom((const uint8_t *)data, length); } + void copyTo(Array &other) const { m_base.copyTo(other.m_base); } + XA_INLINE const T *data() const { return (const T *)m_base.buffer; } + XA_INLINE T *data() { return (T *)m_base.buffer; } + void destroy() { m_base.destroy(); } + XA_INLINE T *end() { return (T *)m_base.buffer + m_base.size; } + XA_INLINE bool isEmpty() const { return m_base.size == 0; } + void insertAt(uint32_t index, const T &value) { m_base.insertAt(index, (const uint8_t *)&value); } + void moveTo(Array &other) { m_base.moveTo(other.m_base); } + void push_back(const T &value) { m_base.push_back((const uint8_t *)&value); } + void push_back(const Array &other) { m_base.push_back(other.m_base); } + void pop_back() { m_base.pop_back(); } + void removeAt(uint32_t index) { m_base.removeAt(index); } + void removeAtFast(uint32_t index) { m_base.removeAtFast(index); } + void reserve(uint32_t desiredSize) { m_base.reserve(desiredSize); } + void resize(uint32_t newSize) { m_base.resize(newSize, true); } + + void runCtors() + { + for (uint32_t i = 0; i < m_base.size; i++) + new (&((T *)m_base.buffer)[i]) T; + } + + void runDtors() + { + for (uint32_t i = 0; i < m_base.size; i++) + ((T *)m_base.buffer)[i].~T(); + } + + void fill(const T &value) + { + auto buffer = (T *)m_base.buffer; + for (uint32_t i = 0; i < m_base.size; i++) + buffer[i] = value; + } + + void fillBytes(uint8_t value) + { + if (m_base.buffer && m_base.size > 0) + memset(m_base.buffer, (int)value, m_base.size * m_base.elementSize); + } + +#if XA_DEBUG_HEAP + void setMemTag(int memTag) { m_base.setMemTag(memTag); } +#endif + + XA_INLINE uint32_t size() const { return m_base.size; } + + XA_INLINE void zeroOutMemory() + { + if (m_base.buffer && m_base.size > 0) + memset(m_base.buffer, 0, m_base.elementSize * m_base.size); + } + +private: + ArrayBase m_base; +}; + +template +struct ArrayView +{ + ArrayView() : data(nullptr), length(0) {} + ArrayView(Array &a) : data(a.data()), length(a.size()) {} + ArrayView(T *_data, uint32_t _length) : data(_data), length(_length) {} + ArrayView &operator=(Array &a) { data = a.data(); length = a.size(); return *this; } + XA_INLINE const T &operator[](uint32_t index) const { XA_DEBUG_ASSERT(index < length); return data[index]; } + XA_INLINE T &operator[](uint32_t index) { XA_DEBUG_ASSERT(index < length); return data[index]; } + T *data; + uint32_t length; +}; + +template +struct ConstArrayView +{ + ConstArrayView() : data(nullptr), length(0) {} + ConstArrayView(const Array &a) : data(a.data()), length(a.size()) {} + ConstArrayView(ArrayView av) : data(av.data), length(av.length) {} + ConstArrayView(const T *_data, uint32_t _length) : data(_data), length(_length) {} + ConstArrayView &operator=(const Array &a) { data = a.data(); length = a.size(); return *this; } + XA_INLINE const T &operator[](uint32_t index) const { XA_DEBUG_ASSERT(index < length); return data[index]; } + const T *data; + uint32_t length; +}; + +/// Basis class to compute tangent space basis, ortogonalizations and to transform vectors from one space to another. +struct Basis +{ + XA_NODISCARD static Vector3 computeTangent(const Vector3 &normal) + { + XA_ASSERT(isNormalized(normal)); + // Choose minimum axis. + Vector3 tangent; + if (fabsf(normal.x) < fabsf(normal.y) && fabsf(normal.x) < fabsf(normal.z)) + tangent = Vector3(1, 0, 0); + else if (fabsf(normal.y) < fabsf(normal.z)) + tangent = Vector3(0, 1, 0); + else + tangent = Vector3(0, 0, 1); + // Ortogonalize + tangent -= normal * dot(normal, tangent); + tangent = normalize(tangent); + return tangent; + } + + XA_NODISCARD static Vector3 computeBitangent(const Vector3 &normal, const Vector3 &tangent) + { + return cross(normal, tangent); + } + + Vector3 tangent = Vector3(0.0f); + Vector3 bitangent = Vector3(0.0f); + Vector3 normal = Vector3(0.0f); +}; + +// Simple bit array. +class BitArray +{ +public: + BitArray() : m_size(0) {} + + BitArray(uint32_t sz) + { + resize(sz); + } + + void resize(uint32_t new_size) + { + m_size = new_size; + m_wordArray.resize((m_size + 31) >> 5); + } + + bool get(uint32_t index) const + { + XA_DEBUG_ASSERT(index < m_size); + return (m_wordArray[index >> 5] & (1 << (index & 31))) != 0; + } + + void set(uint32_t index) + { + XA_DEBUG_ASSERT(index < m_size); + m_wordArray[index >> 5] |= (1 << (index & 31)); + } + + void unset(uint32_t index) + { + XA_DEBUG_ASSERT(index < m_size); + m_wordArray[index >> 5] &= ~(1 << (index & 31)); + } + + void zeroOutMemory() + { + m_wordArray.zeroOutMemory(); + } + +private: + uint32_t m_size; // Number of bits stored. + Array m_wordArray; +}; + +class BitImage +{ +public: + BitImage() : m_width(0), m_height(0), m_rowStride(0), m_data(MemTag::BitImage) {} + + BitImage(uint32_t w, uint32_t h) : m_width(w), m_height(h), m_data(MemTag::BitImage) + { + m_rowStride = (m_width + 63) >> 6; + m_data.resize(m_rowStride * m_height); + m_data.zeroOutMemory(); + } + + BitImage(const BitImage &other) = delete; + BitImage &operator=(const BitImage &other) = delete; + uint32_t width() const { return m_width; } + uint32_t height() const { return m_height; } + + void copyTo(BitImage &other) + { + other.m_width = m_width; + other.m_height = m_height; + other.m_rowStride = m_rowStride; + m_data.copyTo(other.m_data); + } + + void resize(uint32_t w, uint32_t h, bool discard) + { + const uint32_t rowStride = (w + 63) >> 6; + if (discard) { + m_data.resize(rowStride * h); + m_data.zeroOutMemory(); + } else { + Array tmp; + tmp.resize(rowStride * h); + memset(tmp.data(), 0, tmp.size() * sizeof(uint64_t)); + // If only height has changed, can copy all rows at once. + if (rowStride == m_rowStride) { + memcpy(tmp.data(), m_data.data(), m_rowStride * min(m_height, h) * sizeof(uint64_t)); + } else if (m_width > 0 && m_height > 0) { + const uint32_t height = min(m_height, h); + for (uint32_t i = 0; i < height; i++) + memcpy(&tmp[i * rowStride], &m_data[i * m_rowStride], min(rowStride, m_rowStride) * sizeof(uint64_t)); + } + tmp.moveTo(m_data); + } + m_width = w; + m_height = h; + m_rowStride = rowStride; + } + + bool get(uint32_t x, uint32_t y) const + { + XA_DEBUG_ASSERT(x < m_width && y < m_height); + const uint32_t index = (x >> 6) + y * m_rowStride; + return (m_data[index] & (UINT64_C(1) << (uint64_t(x) & UINT64_C(63)))) != 0; + } + + void set(uint32_t x, uint32_t y) + { + XA_DEBUG_ASSERT(x < m_width && y < m_height); + const uint32_t index = (x >> 6) + y * m_rowStride; + m_data[index] |= UINT64_C(1) << (uint64_t(x) & UINT64_C(63)); + XA_DEBUG_ASSERT(get(x, y)); + } + + void zeroOutMemory() + { + m_data.zeroOutMemory(); + } + + bool canBlit(const BitImage &image, uint32_t offsetX, uint32_t offsetY) const + { + for (uint32_t y = 0; y < image.m_height; y++) { + const uint32_t thisY = y + offsetY; + if (thisY >= m_height) + continue; + uint32_t x = 0; + for (;;) { + const uint32_t thisX = x + offsetX; + if (thisX >= m_width) + break; + const uint32_t thisBlockShift = thisX % 64; + const uint64_t thisBlock = m_data[(thisX >> 6) + thisY * m_rowStride] >> thisBlockShift; + const uint32_t blockShift = x % 64; + const uint64_t block = image.m_data[(x >> 6) + y * image.m_rowStride] >> blockShift; + if ((thisBlock & block) != 0) + return false; + x += 64 - max(thisBlockShift, blockShift); + if (x >= image.m_width) + break; + } + } + return true; + } + + void dilate(uint32_t padding) + { + BitImage tmp(m_width, m_height); + for (uint32_t p = 0; p < padding; p++) { + tmp.zeroOutMemory(); + for (uint32_t y = 0; y < m_height; y++) { + for (uint32_t x = 0; x < m_width; x++) { + bool b = get(x, y); + if (!b) { + if (x > 0) { + b |= get(x - 1, y); + if (y > 0) b |= get(x - 1, y - 1); + if (y < m_height - 1) b |= get(x - 1, y + 1); + } + if (y > 0) b |= get(x, y - 1); + if (y < m_height - 1) b |= get(x, y + 1); + if (x < m_width - 1) { + b |= get(x + 1, y); + if (y > 0) b |= get(x + 1, y - 1); + if (y < m_height - 1) b |= get(x + 1, y + 1); + } + } + if (b) + tmp.set(x, y); + } + } + tmp.m_data.copyTo(m_data); + } + } + +private: + uint32_t m_width; + uint32_t m_height; + uint32_t m_rowStride; // In uint64_t's + Array m_data; +}; + +// From Fast-BVH +class BVH +{ +public: + BVH(const Array &objectAabbs, uint32_t leafSize = 4) : m_objectIds(MemTag::BVH), m_nodes(MemTag::BVH) + { + m_objectAabbs = &objectAabbs; + if (m_objectAabbs->isEmpty()) + return; + m_objectIds.resize(objectAabbs.size()); + for (uint32_t i = 0; i < m_objectIds.size(); i++) + m_objectIds[i] = i; + BuildEntry todo[128]; + uint32_t stackptr = 0; + const uint32_t kRoot = 0xfffffffc; + const uint32_t kUntouched = 0xffffffff; + const uint32_t kTouchedTwice = 0xfffffffd; + // Push the root + todo[stackptr].start = 0; + todo[stackptr].end = objectAabbs.size(); + todo[stackptr].parent = kRoot; + stackptr++; + Node node; + m_nodes.reserve(objectAabbs.size() * 2); + uint32_t nNodes = 0; + while(stackptr > 0) { + // Pop the next item off of the stack + const BuildEntry &bnode = todo[--stackptr]; + const uint32_t start = bnode.start; + const uint32_t end = bnode.end; + const uint32_t nPrims = end - start; + nNodes++; + node.start = start; + node.nPrims = nPrims; + node.rightOffset = kUntouched; + // Calculate the bounding box for this node + AABB bb(objectAabbs[m_objectIds[start]]); + AABB bc(objectAabbs[m_objectIds[start]].centroid()); + for(uint32_t p = start + 1; p < end; ++p) { + bb.expandToInclude(objectAabbs[m_objectIds[p]]); + bc.expandToInclude(objectAabbs[m_objectIds[p]].centroid()); + } + node.aabb = bb; + // If the number of primitives at this point is less than the leaf + // size, then this will become a leaf. (Signified by rightOffset == 0) + if (nPrims <= leafSize) + node.rightOffset = 0; + m_nodes.push_back(node); + // Child touches parent... + // Special case: Don't do this for the root. + if (bnode.parent != kRoot) { + m_nodes[bnode.parent].rightOffset--; + // When this is the second touch, this is the right child. + // The right child sets up the offset for the flat tree. + if (m_nodes[bnode.parent].rightOffset == kTouchedTwice ) + m_nodes[bnode.parent].rightOffset = nNodes - 1 - bnode.parent; + } + // If this is a leaf, no need to subdivide. + if (node.rightOffset == 0) + continue; + // Set the split dimensions + const uint32_t split_dim = bc.maxDimension(); + // Split on the center of the longest axis + const float split_coord = 0.5f * ((&bc.min.x)[split_dim] + (&bc.max.x)[split_dim]); + // Partition the list of objects on this split + uint32_t mid = start; + for (uint32_t i = start; i < end; ++i) { + const Vector3 centroid(objectAabbs[m_objectIds[i]].centroid()); + if ((¢roid.x)[split_dim] < split_coord) { + swap(m_objectIds[i], m_objectIds[mid]); + ++mid; + } + } + // If we get a bad split, just choose the center... + if (mid == start || mid == end) + mid = start + (end - start) / 2; + // Push right child + todo[stackptr].start = mid; + todo[stackptr].end = end; + todo[stackptr].parent = nNodes - 1; + stackptr++; + // Push left child + todo[stackptr].start = start; + todo[stackptr].end = mid; + todo[stackptr].parent = nNodes - 1; + stackptr++; + } + } + + void query(const AABB &queryAabb, Array &result) const + { + result.clear(); + // Working set + uint32_t todo[64]; + int32_t stackptr = 0; + // "Push" on the root node to the working set + todo[stackptr] = 0; + while(stackptr >= 0) { + // Pop off the next node to work on. + const int ni = todo[stackptr--]; + const Node &node = m_nodes[ni]; + // Is leaf -> Intersect + if (node.rightOffset == 0) { + for(uint32_t o = 0; o < node.nPrims; ++o) { + const uint32_t obj = node.start + o; + if (queryAabb.intersect((*m_objectAabbs)[m_objectIds[obj]])) + result.push_back(m_objectIds[obj]); + } + } else { // Not a leaf + const uint32_t left = ni + 1; + const uint32_t right = ni + node.rightOffset; + if (queryAabb.intersect(m_nodes[left].aabb)) + todo[++stackptr] = left; + if (queryAabb.intersect(m_nodes[right].aabb)) + todo[++stackptr] = right; + } + } + } + +private: + struct BuildEntry + { + uint32_t parent; // If non-zero then this is the index of the parent. (used in offsets) + uint32_t start, end; // The range of objects in the object list covered by this node. + }; + + struct Node + { + AABB aabb; + uint32_t start, nPrims, rightOffset; + }; + + const Array *m_objectAabbs; + Array m_objectIds; + Array m_nodes; +}; + +struct Fit +{ + static bool computeBasis(ConstArrayView points, Basis *basis) + { + if (computeLeastSquaresNormal(points, &basis->normal)) { + basis->tangent = Basis::computeTangent(basis->normal); + basis->bitangent = Basis::computeBitangent(basis->normal, basis->tangent); + return true; + } + return computeEigen(points, basis); + } + +private: + // Fit a plane to a collection of points. + // Fast, and accurate to within a few degrees. + // Returns None if the points do not span a plane. + // https://www.ilikebigbits.com/2015_03_04_plane_from_points.html + static bool computeLeastSquaresNormal(ConstArrayView points, Vector3 *normal) + { + XA_DEBUG_ASSERT(points.length >= 3); + if (points.length == 3) { + *normal = normalize(cross(points[2] - points[0], points[1] - points[0])); + return true; + } + const float invN = 1.0f / float(points.length); + Vector3 centroid(0.0f); + for (uint32_t i = 0; i < points.length; i++) + centroid += points[i]; + centroid *= invN; + // Calculate full 3x3 covariance matrix, excluding symmetries: + float xx = 0.0f, xy = 0.0f, xz = 0.0f, yy = 0.0f, yz = 0.0f, zz = 0.0f; + for (uint32_t i = 0; i < points.length; i++) { + Vector3 r = points[i] - centroid; + xx += r.x * r.x; + xy += r.x * r.y; + xz += r.x * r.z; + yy += r.y * r.y; + yz += r.y * r.z; + zz += r.z * r.z; + } +#if 0 + xx *= invN; + xy *= invN; + xz *= invN; + yy *= invN; + yz *= invN; + zz *= invN; + Vector3 weighted_dir(0.0f); + { + float det_x = yy * zz - yz * yz; + const Vector3 axis_dir(det_x, xz * yz - xy * zz, xy * yz - xz * yy); + float weight = det_x * det_x; + if (dot(weighted_dir, axis_dir) < 0.0f) + weight = -weight; + weighted_dir += axis_dir * weight; + } + { + float det_y = xx * zz - xz * xz; + const Vector3 axis_dir(xz * yz - xy * zz, det_y, xy * xz - yz * xx); + float weight = det_y * det_y; + if (dot(weighted_dir, axis_dir) < 0.0f) + weight = -weight; + weighted_dir += axis_dir * weight; + } + { + float det_z = xx * yy - xy * xy; + const Vector3 axis_dir(xy * yz - xz * yy, xy * xz - yz * xx, det_z); + float weight = det_z * det_z; + if (dot(weighted_dir, axis_dir) < 0.0f) + weight = -weight; + weighted_dir += axis_dir * weight; + } + *normal = normalize(weighted_dir, kEpsilon); +#else + const float det_x = yy * zz - yz * yz; + const float det_y = xx * zz - xz * xz; + const float det_z = xx * yy - xy * xy; + const float det_max = max(det_x, max(det_y, det_z)); + if (det_max <= 0.0f) + return false; // The points don't span a plane + // Pick path with best conditioning: + Vector3 dir(0.0f); + if (det_max == det_x) + dir = Vector3(det_x,xz * yz - xy * zz,xy * yz - xz * yy); + else if (det_max == det_y) + dir = Vector3(xz * yz - xy * zz, det_y, xy * xz - yz * xx); + else if (det_max == det_z) + dir = Vector3(xy * yz - xz * yy, xy * xz - yz * xx, det_z); + const float len = length(dir); + if (isZero(len, kEpsilon)) + return false; + *normal = dir * (1.0f / len); +#endif + return isNormalized(*normal); + } + + static bool computeEigen(ConstArrayView points, Basis *basis) + { + float matrix[6]; + computeCovariance(points, matrix); + if (matrix[0] == 0 && matrix[3] == 0 && matrix[5] == 0) + return false; + float eigenValues[3]; + Vector3 eigenVectors[3]; + if (!eigenSolveSymmetric3(matrix, eigenValues, eigenVectors)) + return false; + basis->normal = normalize(eigenVectors[2]); + basis->tangent = normalize(eigenVectors[0]); + basis->bitangent = normalize(eigenVectors[1]); + return true; + } + + static Vector3 computeCentroid(ConstArrayView points) + { + Vector3 centroid(0.0f); + for (uint32_t i = 0; i < points.length; i++) + centroid += points[i]; + centroid /= float(points.length); + return centroid; + } + + static Vector3 computeCovariance(ConstArrayView points, float * covariance) + { + // compute the centroid + Vector3 centroid = computeCentroid(points); + // compute covariance matrix + for (int i = 0; i < 6; i++) { + covariance[i] = 0.0f; + } + for (uint32_t i = 0; i < points.length; i++) { + Vector3 v = points[i] - centroid; + covariance[0] += v.x * v.x; + covariance[1] += v.x * v.y; + covariance[2] += v.x * v.z; + covariance[3] += v.y * v.y; + covariance[4] += v.y * v.z; + covariance[5] += v.z * v.z; + } + return centroid; + } + + // Tridiagonal solver from Charles Bloom. + // Householder transforms followed by QL decomposition. + // Seems to be based on the code from Numerical Recipes in C. + static bool eigenSolveSymmetric3(const float matrix[6], float eigenValues[3], Vector3 eigenVectors[3]) + { + XA_DEBUG_ASSERT(matrix != nullptr && eigenValues != nullptr && eigenVectors != nullptr); + float subd[3]; + float diag[3]; + float work[3][3]; + work[0][0] = matrix[0]; + work[0][1] = work[1][0] = matrix[1]; + work[0][2] = work[2][0] = matrix[2]; + work[1][1] = matrix[3]; + work[1][2] = work[2][1] = matrix[4]; + work[2][2] = matrix[5]; + EigenSolver3_Tridiagonal(work, diag, subd); + if (!EigenSolver3_QLAlgorithm(work, diag, subd)) { + for (int i = 0; i < 3; i++) { + eigenValues[i] = 0; + eigenVectors[i] = Vector3(0); + } + return false; + } + for (int i = 0; i < 3; i++) { + eigenValues[i] = (float)diag[i]; + } + // eigenvectors are the columns; make them the rows : + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + (&eigenVectors[j].x)[i] = (float) work[i][j]; + } + } + // shuffle to sort by singular value : + if (eigenValues[2] > eigenValues[0] && eigenValues[2] > eigenValues[1]) { + swap(eigenValues[0], eigenValues[2]); + swap(eigenVectors[0], eigenVectors[2]); + } + if (eigenValues[1] > eigenValues[0]) { + swap(eigenValues[0], eigenValues[1]); + swap(eigenVectors[0], eigenVectors[1]); + } + if (eigenValues[2] > eigenValues[1]) { + swap(eigenValues[1], eigenValues[2]); + swap(eigenVectors[1], eigenVectors[2]); + } + XA_DEBUG_ASSERT(eigenValues[0] >= eigenValues[1] && eigenValues[0] >= eigenValues[2]); + XA_DEBUG_ASSERT(eigenValues[1] >= eigenValues[2]); + return true; + } + +private: + static void EigenSolver3_Tridiagonal(float mat[3][3], float *diag, float *subd) + { + // Householder reduction T = Q^t M Q + // Input: + // mat, symmetric 3x3 matrix M + // Output: + // mat, orthogonal matrix Q + // diag, diagonal entries of T + // subd, subdiagonal entries of T (T is symmetric) + const float epsilon = 1e-08f; + float a = mat[0][0]; + float b = mat[0][1]; + float c = mat[0][2]; + float d = mat[1][1]; + float e = mat[1][2]; + float f = mat[2][2]; + diag[0] = a; + subd[2] = 0.f; + if (fabsf(c) >= epsilon) { + const float ell = sqrtf(b * b + c * c); + b /= ell; + c /= ell; + const float q = 2 * b * e + c * (f - d); + diag[1] = d + c * q; + diag[2] = f - c * q; + subd[0] = ell; + subd[1] = e - b * q; + mat[0][0] = 1; + mat[0][1] = 0; + mat[0][2] = 0; + mat[1][0] = 0; + mat[1][1] = b; + mat[1][2] = c; + mat[2][0] = 0; + mat[2][1] = c; + mat[2][2] = -b; + } else { + diag[1] = d; + diag[2] = f; + subd[0] = b; + subd[1] = e; + mat[0][0] = 1; + mat[0][1] = 0; + mat[0][2] = 0; + mat[1][0] = 0; + mat[1][1] = 1; + mat[1][2] = 0; + mat[2][0] = 0; + mat[2][1] = 0; + mat[2][2] = 1; + } + } + + static bool EigenSolver3_QLAlgorithm(float mat[3][3], float *diag, float *subd) + { + // QL iteration with implicit shifting to reduce matrix from tridiagonal + // to diagonal + const int maxiter = 32; + for (int ell = 0; ell < 3; ell++) { + int iter; + for (iter = 0; iter < maxiter; iter++) { + int m; + for (m = ell; m <= 1; m++) { + float dd = fabsf(diag[m]) + fabsf(diag[m + 1]); + if ( fabsf(subd[m]) + dd == dd ) + break; + } + if ( m == ell ) + break; + float g = (diag[ell + 1] - diag[ell]) / (2 * subd[ell]); + float r = sqrtf(g * g + 1); + if ( g < 0 ) + g = diag[m] - diag[ell] + subd[ell] / (g - r); + else + g = diag[m] - diag[ell] + subd[ell] / (g + r); + float s = 1, c = 1, p = 0; + for (int i = m - 1; i >= ell; i--) { + float f = s * subd[i], b = c * subd[i]; + if ( fabsf(f) >= fabsf(g) ) { + c = g / f; + r = sqrtf(c * c + 1); + subd[i + 1] = f * r; + c *= (s = 1 / r); + } else { + s = f / g; + r = sqrtf(s * s + 1); + subd[i + 1] = g * r; + s *= (c = 1 / r); + } + g = diag[i + 1] - p; + r = (diag[i] - g) * s + 2 * b * c; + p = s * r; + diag[i + 1] = g + p; + g = c * r - b; + for (int k = 0; k < 3; k++) { + f = mat[k][i + 1]; + mat[k][i + 1] = s * mat[k][i] + c * f; + mat[k][i] = c * mat[k][i] - s * f; + } + } + diag[ell] -= p; + subd[ell] = g; + subd[m] = 0; + } + if ( iter == maxiter ) + // should not get here under normal circumstances + return false; + } + return true; + } +}; + +static uint32_t sdbmHash(const void *data_in, uint32_t size, uint32_t h = 5381) +{ + const uint8_t *data = (const uint8_t *) data_in; + uint32_t i = 0; + while (i < size) { + h = (h << 16) + (h << 6) - h + (uint32_t ) data[i++]; + } + return h; +} + +template +static uint32_t hash(const T &t, uint32_t h = 5381) +{ + return sdbmHash(&t, sizeof(T), h); +} + +template +struct Hash +{ + uint32_t operator()(const Key &k) const { return hash(k); } +}; + +template +struct PassthroughHash +{ + uint32_t operator()(const Key &k) const { return (uint32_t)k; } +}; + +template +struct Equal +{ + bool operator()(const Key &k0, const Key &k1) const { return k0 == k1; } +}; + +template, typename E = Equal > +class HashMap +{ +public: + HashMap(int memTag, uint32_t size) : m_memTag(memTag), m_size(size), m_numSlots(0), m_slots(nullptr), m_keys(memTag), m_next(memTag) + { + } + + ~HashMap() + { + if (m_slots) + XA_FREE(m_slots); + } + + void destroy() + { + if (m_slots) { + XA_FREE(m_slots); + m_slots = nullptr; + } + m_keys.destroy(); + m_next.destroy(); + } + + uint32_t add(const Key &key) + { + if (!m_slots) + alloc(); + const uint32_t hash = computeHash(key); + m_keys.push_back(key); + m_next.push_back(m_slots[hash]); + m_slots[hash] = m_next.size() - 1; + return m_keys.size() - 1; + } + + uint32_t get(const Key &key) const + { + if (!m_slots) + return UINT32_MAX; + return find(key, m_slots[computeHash(key)]); + } + + uint32_t getNext(const Key &key, uint32_t current) const + { + return find(key, m_next[current]); + } + +private: + void alloc() + { + XA_DEBUG_ASSERT(m_size > 0); + m_numSlots = nextPowerOfTwo(m_size); + auto minNumSlots = uint32_t(m_size * 1.3); + if (m_numSlots < minNumSlots) + m_numSlots = nextPowerOfTwo(minNumSlots); + m_slots = XA_ALLOC_ARRAY(m_memTag, uint32_t, m_numSlots); + for (uint32_t i = 0; i < m_numSlots; i++) + m_slots[i] = UINT32_MAX; + m_keys.reserve(m_size); + m_next.reserve(m_size); + } + + uint32_t computeHash(const Key &key) const + { + H hash; + return hash(key) & (m_numSlots - 1); + } + + uint32_t find(const Key &key, uint32_t current) const + { + E equal; + while (current != UINT32_MAX) { + if (equal(m_keys[current], key)) + return current; + current = m_next[current]; + } + return current; + } + + int m_memTag; + uint32_t m_size; + uint32_t m_numSlots; + uint32_t *m_slots; + Array m_keys; + Array m_next; +}; + +template +static void insertionSort(T *data, uint32_t length) +{ + for (int32_t i = 1; i < (int32_t)length; i++) { + T x = data[i]; + int32_t j = i - 1; + while (j >= 0 && x < data[j]) { + data[j + 1] = data[j]; + j--; + } + data[j + 1] = x; + } +} + +class KISSRng +{ +public: + KISSRng() { reset(); } + + void reset() + { + x = 123456789; + y = 362436000; + z = 521288629; + c = 7654321; + } + + uint32_t getRange(uint32_t range) + { + if (range == 0) + return 0; + x = 69069 * x + 12345; + y ^= (y << 13); + y ^= (y >> 17); + y ^= (y << 5); + uint64_t t = 698769069ULL * z + c; + c = (t >> 32); + return (x + y + (z = (uint32_t)t)) % (range + 1); + } + +private: + uint32_t x, y, z, c; +}; + +// Based on Pierre Terdiman's and Michael Herf's source code. +// http://www.codercorner.com/RadixSortRevisited.htm +// http://www.stereopsis.com/radix.html +class RadixSort +{ +public: + void sort(ConstArrayView input) + { + if (input.length == 0) { + m_buffer1.clear(); + m_buffer2.clear(); + m_ranks = m_buffer1.data(); + m_ranks2 = m_buffer2.data(); + return; + } + // Resize lists if needed + m_buffer1.resize(input.length); + m_buffer2.resize(input.length); + m_ranks = m_buffer1.data(); + m_ranks2 = m_buffer2.data(); + m_validRanks = false; + if (input.length < 32) + insertionSort(input); + else { + // @@ Avoid touching the input multiple times. + for (uint32_t i = 0; i < input.length; i++) { + floatFlip((uint32_t &)input[i]); + } + radixSort(ConstArrayView((const uint32_t *)input.data, input.length)); + for (uint32_t i = 0; i < input.length; i++) { + ifloatFlip((uint32_t &)input[i]); + } + } + } + + // Access to results. m_ranks is a list of indices in sorted order, i.e. in the order you may further process your data + const uint32_t *ranks() const + { + XA_DEBUG_ASSERT(m_validRanks); + return m_ranks; + } + +private: + uint32_t *m_ranks, *m_ranks2; + Array m_buffer1, m_buffer2; + bool m_validRanks = false; + + void floatFlip(uint32_t &f) + { + int32_t mask = (int32_t(f) >> 31) | 0x80000000; // Warren Hunt, Manchor Ko. + f ^= mask; + } + + void ifloatFlip(uint32_t &f) + { + uint32_t mask = ((f >> 31) - 1) | 0x80000000; // Michael Herf. + f ^= mask; + } + + void createHistograms(ConstArrayView input, uint32_t *histogram) + { + const uint32_t bucketCount = sizeof(uint32_t); + // Init bucket pointers. + uint32_t *h[bucketCount]; + for (uint32_t i = 0; i < bucketCount; i++) { + h[i] = histogram + 256 * i; + } + // Clear histograms. + memset(histogram, 0, 256 * bucketCount * sizeof(uint32_t)); + // @@ Add support for signed integers. + // Build histograms. + const uint8_t *p = (const uint8_t *)input.data; // @@ Does this break aliasing rules? + const uint8_t *pe = p + input.length * sizeof(uint32_t); + while (p != pe) { + h[0][*p++]++, h[1][*p++]++, h[2][*p++]++, h[3][*p++]++; + } + } + + void insertionSort(ConstArrayView input) + { + if (!m_validRanks) { + m_ranks[0] = 0; + for (uint32_t i = 1; i != input.length; ++i) { + int rank = m_ranks[i] = i; + uint32_t j = i; + while (j != 0 && input[rank] < input[m_ranks[j - 1]]) { + m_ranks[j] = m_ranks[j - 1]; + --j; + } + if (i != j) { + m_ranks[j] = rank; + } + } + m_validRanks = true; + } else { + for (uint32_t i = 1; i != input.length; ++i) { + int rank = m_ranks[i]; + uint32_t j = i; + while (j != 0 && input[rank] < input[m_ranks[j - 1]]) { + m_ranks[j] = m_ranks[j - 1]; + --j; + } + if (i != j) { + m_ranks[j] = rank; + } + } + } + } + + void radixSort(ConstArrayView input) + { + const uint32_t P = sizeof(uint32_t); // pass count + // Allocate histograms & offsets on the stack + uint32_t histogram[256 * P]; + uint32_t *link[256]; + createHistograms(input, histogram); + // Radix sort, j is the pass number (0=LSB, P=MSB) + for (uint32_t j = 0; j < P; j++) { + // Pointer to this bucket. + const uint32_t *h = &histogram[j * 256]; + auto inputBytes = (const uint8_t *)input.data; // @@ Is this aliasing legal? + inputBytes += j; + if (h[inputBytes[0]] == input.length) { + // Skip this pass, all values are the same. + continue; + } + // Create offsets + link[0] = m_ranks2; + for (uint32_t i = 1; i < 256; i++) link[i] = link[i - 1] + h[i - 1]; + // Perform Radix Sort + if (!m_validRanks) { + for (uint32_t i = 0; i < input.length; i++) { + *link[inputBytes[i * P]]++ = i; + } + m_validRanks = true; + } else { + for (uint32_t i = 0; i < input.length; i++) { + const uint32_t idx = m_ranks[i]; + *link[inputBytes[idx * P]]++ = idx; + } + } + // Swap pointers for next pass. Valid indices - the most recent ones - are in m_ranks after the swap. + swap(m_ranks, m_ranks2); + } + // All values were equal, generate linear ranks. + if (!m_validRanks) { + for (uint32_t i = 0; i < input.length; i++) + m_ranks[i] = i; + m_validRanks = true; + } + } +}; + +// Wrapping this in a class allows temporary arrays to be re-used. +class BoundingBox2D +{ +public: + Vector2 majorAxis, minorAxis, minCorner, maxCorner; + + void clear() + { + m_boundaryVertices.clear(); + } + + void appendBoundaryVertex(Vector2 v) + { + m_boundaryVertices.push_back(v); + } + + // This should compute convex hull and use rotating calipers to find the best box. Currently it uses a brute force method. + // If vertices are empty, the boundary vertices are used. + void compute(ConstArrayView vertices = ConstArrayView()) + { + XA_DEBUG_ASSERT(!m_boundaryVertices.isEmpty()); + if (vertices.length == 0) + vertices = m_boundaryVertices; + convexHull(m_boundaryVertices, m_hull, 0.00001f); + // @@ Ideally I should use rotating calipers to find the best box. Using brute force for now. + float best_area = FLT_MAX; + Vector2 best_min(0); + Vector2 best_max(0); + Vector2 best_axis(0); + const uint32_t hullCount = m_hull.size(); + for (uint32_t i = 0, j = hullCount - 1; i < hullCount; j = i, i++) { + if (equal(m_hull[i], m_hull[j], kEpsilon)) + continue; + Vector2 axis = normalize(m_hull[i] - m_hull[j]); + XA_DEBUG_ASSERT(isFinite(axis)); + // Compute bounding box. + Vector2 box_min(FLT_MAX, FLT_MAX); + Vector2 box_max(-FLT_MAX, -FLT_MAX); + // Consider all points, not only boundary points, in case the input chart is malformed. + for (uint32_t v = 0; v < vertices.length; v++) { + const Vector2 &point = vertices[v]; + const float x = dot(axis, point); + const float y = dot(Vector2(-axis.y, axis.x), point); + box_min.x = min(box_min.x, x); + box_max.x = max(box_max.x, x); + box_min.y = min(box_min.y, y); + box_max.y = max(box_max.y, y); + } + // Compute box area. + const float area = (box_max.x - box_min.x) * (box_max.y - box_min.y); + if (area < best_area) { + best_area = area; + best_min = box_min; + best_max = box_max; + best_axis = axis; + } + } + majorAxis = best_axis; + minorAxis = Vector2(-best_axis.y, best_axis.x); + minCorner = best_min; + maxCorner = best_max; + XA_ASSERT(isFinite(majorAxis) && isFinite(minorAxis) && isFinite(minCorner)); + } + +private: + // Compute the convex hull using Graham Scan. + void convexHull(ConstArrayView input, Array &output, float epsilon) + { + m_coords.resize(input.length); + for (uint32_t i = 0; i < input.length; i++) + m_coords[i] = input[i].x; + m_radix.sort(m_coords); + const uint32_t *ranks = m_radix.ranks(); + m_top.clear(); + m_bottom.clear(); + m_top.reserve(input.length); + m_bottom.reserve(input.length); + Vector2 P = input[ranks[0]]; + Vector2 Q = input[ranks[input.length - 1]]; + float topy = max(P.y, Q.y); + float boty = min(P.y, Q.y); + for (uint32_t i = 0; i < input.length; i++) { + Vector2 p = input[ranks[i]]; + if (p.y >= boty) + m_top.push_back(p); + } + for (uint32_t i = 0; i < input.length; i++) { + Vector2 p = input[ranks[input.length - 1 - i]]; + if (p.y <= topy) + m_bottom.push_back(p); + } + // Filter top list. + output.clear(); + XA_DEBUG_ASSERT(m_top.size() >= 2); + output.push_back(m_top[0]); + output.push_back(m_top[1]); + for (uint32_t i = 2; i < m_top.size(); ) { + Vector2 a = output[output.size() - 2]; + Vector2 b = output[output.size() - 1]; + Vector2 c = m_top[i]; + float area = triangleArea(a, b, c); + if (area >= -epsilon) + output.pop_back(); + if (area < -epsilon || output.size() == 1) { + output.push_back(c); + i++; + } + } + uint32_t top_count = output.size(); + XA_DEBUG_ASSERT(m_bottom.size() >= 2); + output.push_back(m_bottom[1]); + // Filter bottom list. + for (uint32_t i = 2; i < m_bottom.size(); ) { + Vector2 a = output[output.size() - 2]; + Vector2 b = output[output.size() - 1]; + Vector2 c = m_bottom[i]; + float area = triangleArea(a, b, c); + if (area >= -epsilon) + output.pop_back(); + if (area < -epsilon || output.size() == top_count) { + output.push_back(c); + i++; + } + } + // Remove duplicate element. + XA_DEBUG_ASSERT(output.size() > 0); + output.pop_back(); + } + + Array m_boundaryVertices; + Array m_coords; + Array m_top, m_bottom, m_hull; + RadixSort m_radix; +}; + +struct EdgeKey +{ + EdgeKey(const EdgeKey &k) : v0(k.v0), v1(k.v1) {} + EdgeKey(uint32_t _v0, uint32_t _v1) : v0(_v0), v1(_v1) {} + bool operator==(const EdgeKey &k) const { return v0 == k.v0 && v1 == k.v1; } + + uint32_t v0; + uint32_t v1; +}; + +struct EdgeHash +{ + uint32_t operator()(const EdgeKey &k) const { return k.v0 * 32768u + k.v1; } +}; + +static uint32_t meshEdgeFace(uint32_t edge) { return edge / 3; } +static uint32_t meshEdgeIndex0(uint32_t edge) { return edge; } + +static uint32_t meshEdgeIndex1(uint32_t edge) +{ + const uint32_t faceFirstEdge = edge / 3 * 3; + return faceFirstEdge + (edge - faceFirstEdge + 1) % 3; +} + +struct MeshFlags +{ + enum + { + HasIgnoredFaces = 1<<0, + HasNormals = 1<<1, + HasMaterials = 1<<2 + }; +}; + +class Mesh +{ +public: + Mesh(float epsilon, uint32_t approxVertexCount, uint32_t approxFaceCount, uint32_t flags = 0, uint32_t id = UINT32_MAX) : m_epsilon(epsilon), m_flags(flags), m_id(id), m_faceIgnore(MemTag::Mesh), m_faceMaterials(MemTag::Mesh), m_indices(MemTag::MeshIndices), m_positions(MemTag::MeshPositions), m_normals(MemTag::MeshNormals), m_texcoords(MemTag::MeshTexcoords), m_nextColocalVertex(MemTag::MeshColocals), m_firstColocalVertex(MemTag::MeshColocals), m_boundaryEdges(MemTag::MeshBoundaries), m_oppositeEdges(MemTag::MeshBoundaries), m_edgeMap(MemTag::MeshEdgeMap, approxFaceCount * 3) + { + m_indices.reserve(approxFaceCount * 3); + m_positions.reserve(approxVertexCount); + m_texcoords.reserve(approxVertexCount); + if (m_flags & MeshFlags::HasIgnoredFaces) + m_faceIgnore.reserve(approxFaceCount); + if (m_flags & MeshFlags::HasNormals) + m_normals.reserve(approxVertexCount); + if (m_flags & MeshFlags::HasMaterials) + m_faceMaterials.reserve(approxFaceCount); + } + + uint32_t flags() const { return m_flags; } + uint32_t id() const { return m_id; } + + void addVertex(const Vector3 &pos, const Vector3 &normal = Vector3(0.0f), const Vector2 &texcoord = Vector2(0.0f)) + { + XA_DEBUG_ASSERT(isFinite(pos)); + m_positions.push_back(pos); + if (m_flags & MeshFlags::HasNormals) + m_normals.push_back(normal); + m_texcoords.push_back(texcoord); + } + + void addFace(const uint32_t *indices, bool ignore = false, uint32_t material = UINT32_MAX) + { + if (m_flags & MeshFlags::HasIgnoredFaces) + m_faceIgnore.push_back(ignore); + if (m_flags & MeshFlags::HasMaterials) + m_faceMaterials.push_back(material); + const uint32_t firstIndex = m_indices.size(); + for (uint32_t i = 0; i < 3; i++) + m_indices.push_back(indices[i]); + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex0 = m_indices[firstIndex + i]; + const uint32_t vertex1 = m_indices[firstIndex + (i + 1) % 3]; + m_edgeMap.add(EdgeKey(vertex0, vertex1)); + } + } + + void createColocalsBVH() + { + const uint32_t vertexCount = m_positions.size(); + Array aabbs(MemTag::BVH); + aabbs.resize(vertexCount); + for (uint32_t i = 0; i < m_positions.size(); i++) + aabbs[i] = AABB(m_positions[i], m_epsilon); + BVH bvh(aabbs); + Array colocals(MemTag::MeshColocals); + Array potential(MemTag::MeshColocals); + m_nextColocalVertex.resize(vertexCount); + m_nextColocalVertex.fillBytes(0xff); + m_firstColocalVertex.resize(vertexCount); + m_firstColocalVertex.fillBytes(0xff); + for (uint32_t i = 0; i < vertexCount; i++) { + if (m_nextColocalVertex[i] != UINT32_MAX) + continue; // Already linked. + // Find other vertices colocal to this one. + colocals.clear(); + colocals.push_back(i); // Always add this vertex. + bvh.query(AABB(m_positions[i], m_epsilon), potential); + for (uint32_t j = 0; j < potential.size(); j++) { + const uint32_t otherVertex = potential[j]; + if (otherVertex != i && equal(m_positions[i], m_positions[otherVertex], m_epsilon) && m_nextColocalVertex[otherVertex] == UINT32_MAX) + colocals.push_back(otherVertex); + } + if (colocals.size() == 1) { + // No colocals for this vertex. + m_nextColocalVertex[i] = i; + m_firstColocalVertex[i] = i; + continue; + } + // Link in ascending order. + insertionSort(colocals.data(), colocals.size()); + for (uint32_t j = 0; j < colocals.size(); j++) { + m_nextColocalVertex[colocals[j]] = colocals[(j + 1) % colocals.size()]; + m_firstColocalVertex[colocals[j]] = colocals[0]; + } + XA_DEBUG_ASSERT(m_nextColocalVertex[i] != UINT32_MAX); + } + } + + void createColocalsHash() + { + const uint32_t vertexCount = m_positions.size(); + HashMap positionToVertexMap(MemTag::Default, vertexCount); + for (uint32_t i = 0; i < vertexCount; i++) + positionToVertexMap.add(m_positions[i]); + Array colocals(MemTag::MeshColocals); + m_nextColocalVertex.resize(vertexCount); + m_nextColocalVertex.fillBytes(0xff); + m_firstColocalVertex.resize(vertexCount); + m_firstColocalVertex.fillBytes(0xff); + for (uint32_t i = 0; i < vertexCount; i++) { + if (m_nextColocalVertex[i] != UINT32_MAX) + continue; // Already linked. + // Find other vertices colocal to this one. + colocals.clear(); + colocals.push_back(i); // Always add this vertex. + uint32_t otherVertex = positionToVertexMap.get(m_positions[i]); + while (otherVertex != UINT32_MAX) { + if (otherVertex != i && equal(m_positions[i], m_positions[otherVertex], m_epsilon) && m_nextColocalVertex[otherVertex] == UINT32_MAX) + colocals.push_back(otherVertex); + otherVertex = positionToVertexMap.getNext(m_positions[i], otherVertex); + } + if (colocals.size() == 1) { + // No colocals for this vertex. + m_nextColocalVertex[i] = i; + m_firstColocalVertex[i] = i; + continue; + } + // Link in ascending order. + insertionSort(colocals.data(), colocals.size()); + for (uint32_t j = 0; j < colocals.size(); j++) { + m_nextColocalVertex[colocals[j]] = colocals[(j + 1) % colocals.size()]; + m_firstColocalVertex[colocals[j]] = colocals[0]; + } + XA_DEBUG_ASSERT(m_nextColocalVertex[i] != UINT32_MAX); + } + } + + void createColocals() + { + if (m_epsilon <= FLT_EPSILON) + createColocalsHash(); + else + createColocalsBVH(); + } + + void createBoundaries() + { + const uint32_t edgeCount = m_indices.size(); + const uint32_t vertexCount = m_positions.size(); + m_oppositeEdges.resize(edgeCount); + m_boundaryEdges.reserve(uint32_t(edgeCount * 0.1f)); + m_isBoundaryVertex.resize(vertexCount); + m_isBoundaryVertex.zeroOutMemory(); + for (uint32_t i = 0; i < edgeCount; i++) + m_oppositeEdges[i] = UINT32_MAX; + const uint32_t faceCount = m_indices.size() / 3; + for (uint32_t i = 0; i < faceCount; i++) { + if (isFaceIgnored(i)) + continue; + for (uint32_t j = 0; j < 3; j++) { + const uint32_t edge = i * 3 + j; + const uint32_t vertex0 = m_indices[edge]; + const uint32_t vertex1 = m_indices[i * 3 + (j + 1) % 3]; + // If there is an edge with opposite winding to this one, the edge isn't on a boundary. + const uint32_t oppositeEdge = findEdge(vertex1, vertex0); + if (oppositeEdge != UINT32_MAX) { + m_oppositeEdges[edge] = oppositeEdge; + } else { + m_boundaryEdges.push_back(edge); + m_isBoundaryVertex.set(vertex0); + m_isBoundaryVertex.set(vertex1); + } + } + } + } + + /// Find edge, test all colocals. + uint32_t findEdge(uint32_t vertex0, uint32_t vertex1) const + { + // Try to find exact vertex match first. + { + EdgeKey key(vertex0, vertex1); + uint32_t edge = m_edgeMap.get(key); + while (edge != UINT32_MAX) { + // Don't find edges of ignored faces. + if (!isFaceIgnored(meshEdgeFace(edge))) + return edge; + edge = m_edgeMap.getNext(key, edge); + } + } + // If colocals were created, try every permutation. + if (!m_nextColocalVertex.isEmpty()) { + uint32_t colocalVertex0 = vertex0; + for (;;) { + uint32_t colocalVertex1 = vertex1; + for (;;) { + EdgeKey key(colocalVertex0, colocalVertex1); + uint32_t edge = m_edgeMap.get(key); + while (edge != UINT32_MAX) { + // Don't find edges of ignored faces. + if (!isFaceIgnored(meshEdgeFace(edge))) + return edge; + edge = m_edgeMap.getNext(key, edge); + } + colocalVertex1 = m_nextColocalVertex[colocalVertex1]; + if (colocalVertex1 == vertex1) + break; // Back to start. + } + colocalVertex0 = m_nextColocalVertex[colocalVertex0]; + if (colocalVertex0 == vertex0) + break; // Back to start. + } + } + return UINT32_MAX; + } + + // Edge map can be destroyed when no longer used to reduce memory usage. It's used by: + // * Mesh::createBoundaries() + // * Mesh::edgeMap() (used by MeshFaceGroups) + void destroyEdgeMap() + { + m_edgeMap.destroy(); + } + +#if XA_DEBUG_EXPORT_OBJ + void writeObjVertices(FILE *file) const + { + for (uint32_t i = 0; i < m_positions.size(); i++) + fprintf(file, "v %g %g %g\n", m_positions[i].x, m_positions[i].y, m_positions[i].z); + if (m_flags & MeshFlags::HasNormals) { + for (uint32_t i = 0; i < m_normals.size(); i++) + fprintf(file, "vn %g %g %g\n", m_normals[i].x, m_normals[i].y, m_normals[i].z); + } + for (uint32_t i = 0; i < m_texcoords.size(); i++) + fprintf(file, "vt %g %g\n", m_texcoords[i].x, m_texcoords[i].y); + } + + void writeObjFace(FILE *file, uint32_t face, uint32_t offset = 0) const + { + fprintf(file, "f "); + for (uint32_t j = 0; j < 3; j++) { + const uint32_t index = m_indices[face * 3 + j] + 1 + offset; // 1-indexed + fprintf(file, "%d/%d/%d%c", index, index, index, j == 2 ? '\n' : ' '); + } + } + + void writeObjBoundaryEges(FILE *file) const + { + if (m_oppositeEdges.isEmpty()) + return; // Boundaries haven't been created. + fprintf(file, "o boundary_edges\n"); + for (uint32_t i = 0; i < edgeCount(); i++) { + if (m_oppositeEdges[i] != UINT32_MAX) + continue; + fprintf(file, "l %d %d\n", m_indices[meshEdgeIndex0(i)] + 1, m_indices[meshEdgeIndex1(i)] + 1); // 1-indexed + } + } + + void writeObjFile(const char *filename) const + { + FILE *file; + XA_FOPEN(file, filename, "w"); + if (!file) + return; + writeObjVertices(file); + fprintf(file, "s off\n"); + fprintf(file, "o object\n"); + for (uint32_t i = 0; i < faceCount(); i++) + writeObjFace(file, i); + writeObjBoundaryEges(file); + fclose(file); + } +#endif + + float computeSurfaceArea() const + { + float area = 0; + for (uint32_t f = 0; f < faceCount(); f++) + area += computeFaceArea(f); + XA_DEBUG_ASSERT(area >= 0); + return area; + } + + // Returned value is always positive, even if some triangles are flipped. + float computeParametricArea() const + { + float area = 0; + for (uint32_t f = 0; f < faceCount(); f++) + area += fabsf(computeFaceParametricArea(f)); // May be negative, depends on texcoord winding. + return area; + } + + float computeFaceArea(uint32_t face) const + { + const Vector3 &p0 = m_positions[m_indices[face * 3 + 0]]; + const Vector3 &p1 = m_positions[m_indices[face * 3 + 1]]; + const Vector3 &p2 = m_positions[m_indices[face * 3 + 2]]; + return length(cross(p1 - p0, p2 - p0)) * 0.5f; + } + + Vector3 computeFaceCentroid(uint32_t face) const + { + Vector3 sum(0.0f); + for (uint32_t i = 0; i < 3; i++) + sum += m_positions[m_indices[face * 3 + i]]; + return sum / 3.0f; + } + + // Average of the edge midpoints weighted by the edge length. + // I want a point inside the triangle, but closer to the cirumcenter. + Vector3 computeFaceCenter(uint32_t face) const + { + const Vector3 &p0 = m_positions[m_indices[face * 3 + 0]]; + const Vector3 &p1 = m_positions[m_indices[face * 3 + 1]]; + const Vector3 &p2 = m_positions[m_indices[face * 3 + 2]]; + const float l0 = length(p1 - p0); + const float l1 = length(p2 - p1); + const float l2 = length(p0 - p2); + const Vector3 m0 = (p0 + p1) * l0 / (l0 + l1 + l2); + const Vector3 m1 = (p1 + p2) * l1 / (l0 + l1 + l2); + const Vector3 m2 = (p2 + p0) * l2 / (l0 + l1 + l2); + return m0 + m1 + m2; + } + + Vector3 computeFaceNormal(uint32_t face) const + { + const Vector3 &p0 = m_positions[m_indices[face * 3 + 0]]; + const Vector3 &p1 = m_positions[m_indices[face * 3 + 1]]; + const Vector3 &p2 = m_positions[m_indices[face * 3 + 2]]; + const Vector3 e0 = p2 - p0; + const Vector3 e1 = p1 - p0; + const Vector3 normalAreaScaled = cross(e0, e1); + return normalizeSafe(normalAreaScaled, Vector3(0, 0, 1)); + } + + float computeFaceParametricArea(uint32_t face) const + { + const Vector2 &t0 = m_texcoords[m_indices[face * 3 + 0]]; + const Vector2 &t1 = m_texcoords[m_indices[face * 3 + 1]]; + const Vector2 &t2 = m_texcoords[m_indices[face * 3 + 2]]; + return triangleArea(t0, t1, t2); + } + + // @@ This is not exactly accurate, we should compare the texture coordinates... + bool isSeam(uint32_t edge) const + { + const uint32_t oppositeEdge = m_oppositeEdges[edge]; + if (oppositeEdge == UINT32_MAX) + return false; // boundary edge + const uint32_t e0 = meshEdgeIndex0(edge); + const uint32_t e1 = meshEdgeIndex1(edge); + const uint32_t oe0 = meshEdgeIndex0(oppositeEdge); + const uint32_t oe1 = meshEdgeIndex1(oppositeEdge); + return m_indices[e0] != m_indices[oe1] || m_indices[e1] != m_indices[oe0]; + } + + bool isTextureSeam(uint32_t edge) const + { + const uint32_t oppositeEdge = m_oppositeEdges[edge]; + if (oppositeEdge == UINT32_MAX) + return false; // boundary edge + const uint32_t e0 = meshEdgeIndex0(edge); + const uint32_t e1 = meshEdgeIndex1(edge); + const uint32_t oe0 = meshEdgeIndex0(oppositeEdge); + const uint32_t oe1 = meshEdgeIndex1(oppositeEdge); + return m_texcoords[m_indices[e0]] != m_texcoords[m_indices[oe1]] || m_texcoords[m_indices[e1]] != m_texcoords[m_indices[oe0]]; + } + + uint32_t firstColocalVertex(uint32_t vertex) const + { + XA_DEBUG_ASSERT(m_firstColocalVertex.size() == m_positions.size()); + return m_firstColocalVertex[vertex]; + } + + XA_INLINE float epsilon() const { return m_epsilon; } + XA_INLINE uint32_t edgeCount() const { return m_indices.size(); } + XA_INLINE uint32_t oppositeEdge(uint32_t edge) const { return m_oppositeEdges[edge]; } + XA_INLINE bool isBoundaryEdge(uint32_t edge) const { return m_oppositeEdges[edge] == UINT32_MAX; } + XA_INLINE const Array &boundaryEdges() const { return m_boundaryEdges; } + XA_INLINE bool isBoundaryVertex(uint32_t vertex) const { return m_isBoundaryVertex.get(vertex); } + XA_INLINE uint32_t vertexCount() const { return m_positions.size(); } + XA_INLINE uint32_t vertexAt(uint32_t i) const { return m_indices[i]; } + XA_INLINE const Vector3 &position(uint32_t vertex) const { return m_positions[vertex]; } + XA_INLINE ConstArrayView positions() const { return m_positions; } + XA_INLINE const Vector3 &normal(uint32_t vertex) const { XA_DEBUG_ASSERT(m_flags & MeshFlags::HasNormals); return m_normals[vertex]; } + XA_INLINE const Vector2 &texcoord(uint32_t vertex) const { return m_texcoords[vertex]; } + XA_INLINE Vector2 &texcoord(uint32_t vertex) { return m_texcoords[vertex]; } + XA_INLINE const ConstArrayView texcoords() const { return m_texcoords; } + XA_INLINE ArrayView texcoords() { return m_texcoords; } + XA_INLINE uint32_t faceCount() const { return m_indices.size() / 3; } + XA_INLINE ConstArrayView indices() const { return m_indices; } + XA_INLINE uint32_t indexCount() const { return m_indices.size(); } + XA_INLINE bool isFaceIgnored(uint32_t face) const { return (m_flags & MeshFlags::HasIgnoredFaces) && m_faceIgnore[face]; } + XA_INLINE uint32_t faceMaterial(uint32_t face) const { return (m_flags & MeshFlags::HasMaterials) ? m_faceMaterials[face] : UINT32_MAX; } + XA_INLINE const HashMap &edgeMap() const { return m_edgeMap; } + +private: + + float m_epsilon; + uint32_t m_flags; + uint32_t m_id; + Array m_faceIgnore; + Array m_faceMaterials; + Array m_indices; + Array m_positions; + Array m_normals; + Array m_texcoords; + + // Populated by createColocals + Array m_nextColocalVertex; // In: vertex index. Out: the vertex index of the next colocal position. + Array m_firstColocalVertex; + + // Populated by createBoundaries + BitArray m_isBoundaryVertex; + Array m_boundaryEdges; + Array m_oppositeEdges; // In: edge index. Out: the index of the opposite edge (i.e. wound the opposite direction). UINT32_MAX if the input edge is a boundary edge. + + HashMap m_edgeMap; + +public: + class FaceEdgeIterator + { + public: + FaceEdgeIterator (const Mesh *mesh, uint32_t face) : m_mesh(mesh), m_face(face), m_relativeEdge(0) + { + m_edge = m_face * 3; + } + + void advance() + { + if (m_relativeEdge < 3) { + m_edge++; + m_relativeEdge++; + } + } + + bool isDone() const + { + return m_relativeEdge == 3; + } + + bool isBoundary() const { return m_mesh->m_oppositeEdges[m_edge] == UINT32_MAX; } + bool isSeam() const { return m_mesh->isSeam(m_edge); } + bool isTextureSeam() const { return m_mesh->isTextureSeam(m_edge); } + uint32_t edge() const { return m_edge; } + uint32_t relativeEdge() const { return m_relativeEdge; } + uint32_t face() const { return m_face; } + uint32_t oppositeEdge() const { return m_mesh->m_oppositeEdges[m_edge]; } + + uint32_t oppositeFace() const + { + const uint32_t oedge = m_mesh->m_oppositeEdges[m_edge]; + if (oedge == UINT32_MAX) + return UINT32_MAX; + return meshEdgeFace(oedge); + } + + uint32_t vertex0() const { return m_mesh->m_indices[m_face * 3 + m_relativeEdge]; } + uint32_t vertex1() const { return m_mesh->m_indices[m_face * 3 + (m_relativeEdge + 1) % 3]; } + const Vector3 &position0() const { return m_mesh->m_positions[vertex0()]; } + const Vector3 &position1() const { return m_mesh->m_positions[vertex1()]; } + const Vector3 &normal0() const { return m_mesh->m_normals[vertex0()]; } + const Vector3 &normal1() const { return m_mesh->m_normals[vertex1()]; } + const Vector2 &texcoord0() const { return m_mesh->m_texcoords[vertex0()]; } + const Vector2 &texcoord1() const { return m_mesh->m_texcoords[vertex1()]; } + + private: + const Mesh *m_mesh; + uint32_t m_face; + uint32_t m_edge; + uint32_t m_relativeEdge; + }; +}; + +struct MeshFaceGroups +{ + typedef uint32_t Handle; + static constexpr Handle kInvalid = UINT32_MAX; + + MeshFaceGroups(const Mesh *mesh) : m_mesh(mesh), m_groups(MemTag::Mesh), m_firstFace(MemTag::Mesh), m_nextFace(MemTag::Mesh), m_faceCount(MemTag::Mesh) {} + XA_INLINE Handle groupAt(uint32_t face) const { return m_groups[face]; } + XA_INLINE uint32_t groupCount() const { return m_faceCount.size(); } + XA_INLINE uint32_t nextFace(uint32_t face) const { return m_nextFace[face]; } + XA_INLINE uint32_t faceCount(uint32_t group) const { return m_faceCount[group]; } + + void compute() + { + m_groups.resize(m_mesh->faceCount()); + m_groups.fillBytes(0xff); // Set all faces to kInvalid + uint32_t firstUnassignedFace = 0; + Handle group = 0; + Array growFaces; + const uint32_t n = m_mesh->faceCount(); + m_nextFace.resize(n); + for (;;) { + // Find an unassigned face. + uint32_t face = UINT32_MAX; + for (uint32_t f = firstUnassignedFace; f < n; f++) { + if (m_groups[f] == kInvalid && !m_mesh->isFaceIgnored(f)) { + face = f; + firstUnassignedFace = f + 1; + break; + } + } + if (face == UINT32_MAX) + break; // All faces assigned to a group (except ignored faces). + m_groups[face] = group; + m_nextFace[face] = UINT32_MAX; + m_firstFace.push_back(face); + growFaces.clear(); + growFaces.push_back(face); + uint32_t prevFace = face, groupFaceCount = 1; + // Find faces connected to the face and assign them to the same group as the face, unless they are already assigned to another group. + for (;;) { + if (growFaces.isEmpty()) + break; + const uint32_t f = growFaces.back(); + growFaces.pop_back(); + const uint32_t material = m_mesh->faceMaterial(f); + for (Mesh::FaceEdgeIterator edgeIt(m_mesh, f); !edgeIt.isDone(); edgeIt.advance()) { + const uint32_t oppositeEdge = m_mesh->findEdge(edgeIt.vertex1(), edgeIt.vertex0()); + if (oppositeEdge == UINT32_MAX) + continue; // Boundary edge. + const uint32_t oppositeFace = meshEdgeFace(oppositeEdge); + if (m_mesh->isFaceIgnored(oppositeFace)) + continue; // Don't add ignored faces to group. + if (m_mesh->faceMaterial(oppositeFace) != material) + continue; // Different material. + if (m_groups[oppositeFace] != kInvalid) + continue; // Connected face is already assigned to another group. + m_groups[oppositeFace] = group; + m_nextFace[oppositeFace] = UINT32_MAX; + if (prevFace != UINT32_MAX) + m_nextFace[prevFace] = oppositeFace; + prevFace = oppositeFace; + groupFaceCount++; + growFaces.push_back(oppositeFace); + } + } + m_faceCount.push_back(groupFaceCount); + group++; + XA_ASSERT(group < kInvalid); + } + } + + class Iterator + { + public: + Iterator(const MeshFaceGroups *meshFaceGroups, Handle group) : m_meshFaceGroups(meshFaceGroups) + { + XA_DEBUG_ASSERT(group != kInvalid); + m_current = m_meshFaceGroups->m_firstFace[group]; + } + + void advance() + { + m_current = m_meshFaceGroups->m_nextFace[m_current]; + } + + bool isDone() const + { + return m_current == UINT32_MAX; + } + + uint32_t face() const + { + return m_current; + } + + private: + const MeshFaceGroups *m_meshFaceGroups; + uint32_t m_current; + }; + +private: + const Mesh *m_mesh; + Array m_groups; + Array m_firstFace; + Array m_nextFace; // In: face. Out: the next face in the same group. + Array m_faceCount; // In: face group. Out: number of faces in the group. +}; + +constexpr MeshFaceGroups::Handle MeshFaceGroups::kInvalid; + +#if XA_CHECK_T_JUNCTIONS +static bool lineIntersectsPoint(const Vector3 &point, const Vector3 &lineStart, const Vector3 &lineEnd, float *t, float epsilon) +{ + float tt; + if (!t) + t = &tt; + *t = 0.0f; + if (equal(lineStart, point, epsilon) || equal(lineEnd, point, epsilon)) + return false; // Vertex lies on either line vertices. + const Vector3 v01 = point - lineStart; + const Vector3 v21 = lineEnd - lineStart; + const float l = length(v21); + const float d = length(cross(v01, v21)) / l; + if (!isZero(d, epsilon)) + return false; + *t = dot(v01, v21) / (l * l); + return *t > kEpsilon && *t < 1.0f - kEpsilon; +} + +// Returns the number of T-junctions found. +static int meshCheckTJunctions(const Mesh &inputMesh) +{ + int count = 0; + const uint32_t vertexCount = inputMesh.vertexCount(); + const uint32_t edgeCount = inputMesh.edgeCount(); + for (uint32_t v = 0; v < vertexCount; v++) { + if (!inputMesh.isBoundaryVertex(v)) + continue; + // Find edges that this vertex overlaps with. + const Vector3 &pos = inputMesh.position(v); + for (uint32_t e = 0; e < edgeCount; e++) { + if (!inputMesh.isBoundaryEdge(e)) + continue; + const Vector3 &edgePos1 = inputMesh.position(inputMesh.vertexAt(meshEdgeIndex0(e))); + const Vector3 &edgePos2 = inputMesh.position(inputMesh.vertexAt(meshEdgeIndex1(e))); + float t; + if (lineIntersectsPoint(pos, edgePos1, edgePos2, &t, inputMesh.epsilon())) + count++; + } + } + return count; +} +#endif + +// References invalid faces and vertices in a mesh. +struct InvalidMeshGeometry +{ + // If meshFaceGroups is not null, invalid faces have the face group MeshFaceGroups::kInvalid. + // If meshFaceGroups is null, invalid faces are Mesh::isFaceIgnored. + void extract(const Mesh *mesh, const MeshFaceGroups *meshFaceGroups) + { + // Copy invalid faces. + m_faces.clear(); + const uint32_t meshFaceCount = mesh->faceCount(); + for (uint32_t f = 0; f < meshFaceCount; f++) { + if ((meshFaceGroups && meshFaceGroups->groupAt(f) == MeshFaceGroups::kInvalid) || (!meshFaceGroups && mesh->isFaceIgnored(f))) + m_faces.push_back(f); + } + // Create *unique* list of vertices of invalid faces. + const uint32_t faceCount = m_faces.size(); + m_indices.resize(faceCount * 3); + const uint32_t approxVertexCount = min(faceCount * 3, mesh->vertexCount()); + m_vertexToSourceVertexMap.clear(); + m_vertexToSourceVertexMap.reserve(approxVertexCount); + HashMap> sourceVertexToVertexMap(MemTag::Mesh, approxVertexCount); + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t face = m_faces[f]; + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = mesh->vertexAt(face * 3 + i); + uint32_t newVertex = sourceVertexToVertexMap.get(vertex); + if (newVertex == UINT32_MAX) { + newVertex = sourceVertexToVertexMap.add(vertex); + m_vertexToSourceVertexMap.push_back(vertex); + } + m_indices[f * 3 + i] = newVertex; + } + } + } + + ConstArrayView faces() const { return m_faces; } + ConstArrayView indices() const { return m_indices; } + ConstArrayView vertices() const { return m_vertexToSourceVertexMap; } + +private: + Array m_faces, m_indices; + Array m_vertexToSourceVertexMap; // Map face vertices to vertices of the source mesh. +}; + +struct Progress +{ + Progress(ProgressCategory category, ProgressFunc func, void *userData, uint32_t maxValue) : cancel(false), m_category(category), m_func(func), m_userData(userData), m_value(0), m_maxValue(maxValue), m_percent(0) + { + if (m_func) { + if (!m_func(category, 0, userData)) + cancel = true; + } + } + + ~Progress() + { + if (m_func) { + if (!m_func(m_category, 100, m_userData)) + cancel = true; + } + } + + void increment(uint32_t value) + { + m_value += value; + update(); + } + + void setMaxValue(uint32_t maxValue) + { + m_maxValue = maxValue; + update(); + } + + std::atomic cancel; + +private: + void update() + { + if (!m_func) + return; + const uint32_t newPercent = uint32_t(ceilf(m_value.load() / (float)m_maxValue.load() * 100.0f)); + if (newPercent != m_percent) { + // Atomic max. + uint32_t oldPercent = m_percent; + while (oldPercent < newPercent && !m_percent.compare_exchange_weak(oldPercent, newPercent)) {} + if (!m_func(m_category, m_percent, m_userData)) + cancel = true; + } + } + + ProgressCategory m_category; + ProgressFunc m_func; + void *m_userData; + std::atomic m_value, m_maxValue, m_percent; +}; + +struct Spinlock +{ + void lock() { while(m_lock.test_and_set(std::memory_order_acquire)) {} } + void unlock() { m_lock.clear(std::memory_order_release); } + +private: + std::atomic_flag m_lock = ATOMIC_FLAG_INIT; +}; + +struct TaskGroupHandle +{ + uint32_t value = UINT32_MAX; +}; + +struct Task +{ + void (*func)(void *groupUserData, void *taskUserData); + void *userData; // Passed to func as taskUserData. +}; + +#if XA_MULTITHREADED +class TaskScheduler +{ +public: + TaskScheduler() : m_shutdown(false) + { + m_threadIndex = 0; + // Max with current task scheduler usage is 1 per thread + 1 deep nesting, but allow for some slop. + m_maxGroups = std::thread::hardware_concurrency() * 4; + m_groups = XA_ALLOC_ARRAY(MemTag::Default, TaskGroup, m_maxGroups); + for (uint32_t i = 0; i < m_maxGroups; i++) { + new (&m_groups[i]) TaskGroup(); + m_groups[i].free = true; + m_groups[i].ref = 0; + m_groups[i].userData = nullptr; + } + m_workers.resize(std::thread::hardware_concurrency() <= 1 ? 1 : std::thread::hardware_concurrency() - 1); + for (uint32_t i = 0; i < m_workers.size(); i++) { + new (&m_workers[i]) Worker(); + m_workers[i].wakeup = false; + m_workers[i].thread = XA_NEW_ARGS(MemTag::Default, std::thread, workerThread, this, &m_workers[i], i + 1); + } + } + + ~TaskScheduler() + { + m_shutdown = true; + for (uint32_t i = 0; i < m_workers.size(); i++) { + Worker &worker = m_workers[i]; + XA_DEBUG_ASSERT(worker.thread); + worker.wakeup = true; + worker.cv.notify_one(); + if (worker.thread->joinable()) + worker.thread->join(); + worker.thread->~thread(); + XA_FREE(worker.thread); + worker.~Worker(); + } + for (uint32_t i = 0; i < m_maxGroups; i++) + m_groups[i].~TaskGroup(); + XA_FREE(m_groups); + } + + uint32_t threadCount() const + { + return max(1u, std::thread::hardware_concurrency()); // Including the main thread. + } + + // userData is passed to Task::func as groupUserData. + TaskGroupHandle createTaskGroup(void *userData = nullptr, uint32_t reserveSize = 0) + { + // Claim the first free group. + for (uint32_t i = 0; i < m_maxGroups; i++) { + TaskGroup &group = m_groups[i]; + bool expected = true; + if (!group.free.compare_exchange_strong(expected, false)) + continue; + group.queueLock.lock(); + group.queueHead = 0; + group.queue.clear(); + group.queue.reserve(reserveSize); + group.queueLock.unlock(); + group.userData = userData; + group.ref = 0; + TaskGroupHandle handle; + handle.value = i; + return handle; + } + XA_DEBUG_ASSERT(false); + TaskGroupHandle handle; + handle.value = UINT32_MAX; + return handle; + } + + void run(TaskGroupHandle handle, const Task &task) + { + XA_DEBUG_ASSERT(handle.value != UINT32_MAX); + TaskGroup &group = m_groups[handle.value]; + group.queueLock.lock(); + group.queue.push_back(task); + group.queueLock.unlock(); + group.ref++; + // Wake up a worker to run this task. + for (uint32_t i = 0; i < m_workers.size(); i++) { + m_workers[i].wakeup = true; + m_workers[i].cv.notify_one(); + } + } + + void wait(TaskGroupHandle *handle) + { + if (handle->value == UINT32_MAX) { + XA_DEBUG_ASSERT(false); + return; + } + // Run tasks from the group queue until empty. + TaskGroup &group = m_groups[handle->value]; + for (;;) { + Task *task = nullptr; + group.queueLock.lock(); + if (group.queueHead < group.queue.size()) + task = &group.queue[group.queueHead++]; + group.queueLock.unlock(); + if (!task) + break; + task->func(group.userData, task->userData); + group.ref--; + } + // Even though the task queue is empty, workers can still be running tasks. + while (group.ref > 0) + std::this_thread::yield(); + group.free = true; + handle->value = UINT32_MAX; + } + + static uint32_t currentThreadIndex() { return m_threadIndex; } + +private: + struct TaskGroup + { + std::atomic free; + Array queue; // Items are never removed. queueHead is incremented to pop items. + uint32_t queueHead = 0; + Spinlock queueLock; + std::atomic ref; // Increment when a task is enqueued, decrement when a task finishes. + void *userData; + }; + + struct Worker + { + std::thread *thread = nullptr; + std::mutex mutex; + std::condition_variable cv; + std::atomic wakeup; + }; + + TaskGroup *m_groups; + Array m_workers; + std::atomic m_shutdown; + uint32_t m_maxGroups; + static thread_local uint32_t m_threadIndex; + + static void workerThread(TaskScheduler *scheduler, Worker *worker, uint32_t threadIndex) + { + m_threadIndex = threadIndex; + std::unique_lock lock(worker->mutex); + for (;;) { + worker->cv.wait(lock, [=]{ return worker->wakeup.load(); }); + worker->wakeup = false; + for (;;) { + if (scheduler->m_shutdown) + return; + // Look for a task in any of the groups and run it. + TaskGroup *group = nullptr; + Task *task = nullptr; + for (uint32_t i = 0; i < scheduler->m_maxGroups; i++) { + group = &scheduler->m_groups[i]; + if (group->free || group->ref == 0) + continue; + group->queueLock.lock(); + if (group->queueHead < group->queue.size()) { + task = &group->queue[group->queueHead++]; + group->queueLock.unlock(); + break; + } + group->queueLock.unlock(); + } + if (!task) + break; + task->func(group->userData, task->userData); + group->ref--; + } + } + } +}; + +thread_local uint32_t TaskScheduler::m_threadIndex; +#else +class TaskScheduler +{ +public: + ~TaskScheduler() + { + for (uint32_t i = 0; i < m_groups.size(); i++) + destroyGroup({ i }); + } + + uint32_t threadCount() const + { + return 1; + } + + TaskGroupHandle createTaskGroup(void *userData = nullptr, uint32_t reserveSize = 0) + { + TaskGroup *group = XA_NEW(MemTag::Default, TaskGroup); + group->queue.reserve(reserveSize); + group->userData = userData; + m_groups.push_back(group); + TaskGroupHandle handle; + handle.value = m_groups.size() - 1; + return handle; + } + + void run(TaskGroupHandle handle, Task task) + { + m_groups[handle.value]->queue.push_back(task); + } + + void wait(TaskGroupHandle *handle) + { + if (handle->value == UINT32_MAX) { + XA_DEBUG_ASSERT(false); + return; + } + TaskGroup *group = m_groups[handle->value]; + for (uint32_t i = 0; i < group->queue.size(); i++) + group->queue[i].func(group->userData, group->queue[i].userData); + group->queue.clear(); + destroyGroup(*handle); + handle->value = UINT32_MAX; + } + + static uint32_t currentThreadIndex() { return 0; } + +private: + void destroyGroup(TaskGroupHandle handle) + { + TaskGroup *group = m_groups[handle.value]; + if (group) { + group->~TaskGroup(); + XA_FREE(group); + m_groups[handle.value] = nullptr; + } + } + + struct TaskGroup + { + Array queue; + void *userData; + }; + + Array m_groups; +}; +#endif + +#if XA_DEBUG_EXPORT_TGA +const uint8_t TGA_TYPE_RGB = 2; +const uint8_t TGA_ORIGIN_UPPER = 0x20; + +#pragma pack(push, 1) +struct TgaHeader +{ + uint8_t id_length; + uint8_t colormap_type; + uint8_t image_type; + uint16_t colormap_index; + uint16_t colormap_length; + uint8_t colormap_size; + uint16_t x_origin; + uint16_t y_origin; + uint16_t width; + uint16_t height; + uint8_t pixel_size; + uint8_t flags; + enum { Size = 18 }; +}; +#pragma pack(pop) + +static void WriteTga(const char *filename, const uint8_t *data, uint32_t width, uint32_t height) +{ + XA_DEBUG_ASSERT(sizeof(TgaHeader) == TgaHeader::Size); + FILE *f; + XA_FOPEN(f, filename, "wb"); + if (!f) + return; + TgaHeader tga; + tga.id_length = 0; + tga.colormap_type = 0; + tga.image_type = TGA_TYPE_RGB; + tga.colormap_index = 0; + tga.colormap_length = 0; + tga.colormap_size = 0; + tga.x_origin = 0; + tga.y_origin = 0; + tga.width = (uint16_t)width; + tga.height = (uint16_t)height; + tga.pixel_size = 24; + tga.flags = TGA_ORIGIN_UPPER; + fwrite(&tga, sizeof(TgaHeader), 1, f); + fwrite(data, sizeof(uint8_t), width * height * 3, f); + fclose(f); +} +#endif + +template +class ThreadLocal +{ +public: + ThreadLocal() + { +#if XA_MULTITHREADED + const uint32_t n = std::thread::hardware_concurrency(); +#else + const uint32_t n = 1; +#endif + m_array = XA_ALLOC_ARRAY(MemTag::Default, T, n); + for (uint32_t i = 0; i < n; i++) + new (&m_array[i]) T; + } + + ~ThreadLocal() + { +#if XA_MULTITHREADED + const uint32_t n = std::thread::hardware_concurrency(); +#else + const uint32_t n = 1; +#endif + for (uint32_t i = 0; i < n; i++) + m_array[i].~T(); + XA_FREE(m_array); + } + + T &get() const + { + return m_array[TaskScheduler::currentThreadIndex()]; + } + +private: + T *m_array; +}; + +// Implemented as a struct so the temporary arrays can be reused. +struct Triangulator +{ + // This is doing a simple ear-clipping algorithm that skips invalid triangles. Ideally, we should + // also sort the ears by angle, start with the ones that have the smallest angle and proceed in order. + void triangulatePolygon(ConstArrayView vertices, ConstArrayView inputIndices, Array &outputIndices) + { + m_polygonVertices.clear(); + m_polygonVertices.reserve(inputIndices.length); + outputIndices.clear(); + if (inputIndices.length == 3) { + // Simple case for triangles. + outputIndices.push_back(inputIndices[0]); + outputIndices.push_back(inputIndices[1]); + outputIndices.push_back(inputIndices[2]); + } + else { + // Build 2D polygon projecting vertices onto normal plane. + // Faces are not necesarily planar, this is for example the case, when the face comes from filling a hole. In such cases + // it's much better to use the best fit plane. + Basis basis; + basis.normal = normalize(cross(vertices[inputIndices[1]] - vertices[inputIndices[0]], vertices[inputIndices[2]] - vertices[inputIndices[1]])); + basis.tangent = basis.computeTangent(basis.normal); + basis.bitangent = basis.computeBitangent(basis.normal, basis.tangent); + const uint32_t edgeCount = inputIndices.length; + m_polygonPoints.clear(); + m_polygonPoints.reserve(edgeCount); + m_polygonAngles.clear(); + m_polygonAngles.reserve(edgeCount); + for (uint32_t i = 0; i < inputIndices.length; i++) { + m_polygonVertices.push_back(inputIndices[i]); + const Vector3 &pos = vertices[inputIndices[i]]; + m_polygonPoints.push_back(Vector2(dot(basis.tangent, pos), dot(basis.bitangent, pos))); + } + m_polygonAngles.resize(edgeCount); + while (m_polygonVertices.size() > 2) { + const uint32_t size = m_polygonVertices.size(); + // Update polygon angles. @@ Update only those that have changed. + float minAngle = kPi2; + uint32_t bestEar = 0; // Use first one if none of them is valid. + bool bestIsValid = false; + for (uint32_t i = 0; i < size; i++) { + uint32_t i0 = i; + uint32_t i1 = (i + 1) % size; // Use Sean's polygon interation trick. + uint32_t i2 = (i + 2) % size; + Vector2 p0 = m_polygonPoints[i0]; + Vector2 p1 = m_polygonPoints[i1]; + Vector2 p2 = m_polygonPoints[i2]; + float d = clamp(dot(p0 - p1, p2 - p1) / (length(p0 - p1) * length(p2 - p1)), -1.0f, 1.0f); + float angle = acosf(d); + float area = triangleArea(p0, p1, p2); + if (area < 0.0f) + angle = kPi2 - angle; + m_polygonAngles[i1] = angle; + if (angle < minAngle || !bestIsValid) { + // Make sure this is a valid ear, if not, skip this point. + bool valid = true; + for (uint32_t j = 0; j < size; j++) { + if (j == i0 || j == i1 || j == i2) + continue; + Vector2 p = m_polygonPoints[j]; + if (pointInTriangle(p, p0, p1, p2)) { + valid = false; + break; + } + } + if (valid || !bestIsValid) { + minAngle = angle; + bestEar = i1; + bestIsValid = valid; + } + } + } + // Clip best ear: + const uint32_t i0 = (bestEar + size - 1) % size; + const uint32_t i1 = (bestEar + 0) % size; + const uint32_t i2 = (bestEar + 1) % size; + outputIndices.push_back(m_polygonVertices[i0]); + outputIndices.push_back(m_polygonVertices[i1]); + outputIndices.push_back(m_polygonVertices[i2]); + m_polygonVertices.removeAt(i1); + m_polygonPoints.removeAt(i1); + m_polygonAngles.removeAt(i1); + } + } + } + +private: + static bool pointInTriangle(const Vector2 &p, const Vector2 &a, const Vector2 &b, const Vector2 &c) + { + return triangleArea(a, b, p) >= kAreaEpsilon && triangleArea(b, c, p) >= kAreaEpsilon && triangleArea(c, a, p) >= kAreaEpsilon; + } + + Array m_polygonVertices; + Array m_polygonAngles; + Array m_polygonPoints; +}; + +class UniformGrid2 +{ +public: + // indices are optional. + void reset(ConstArrayView positions, ConstArrayView indices = ConstArrayView(), uint32_t reserveEdgeCount = 0) + { + m_edges.clear(); + if (reserveEdgeCount > 0) + m_edges.reserve(reserveEdgeCount); + m_positions = positions; + m_indices = indices; + m_cellDataOffsets.clear(); + } + + void append(uint32_t edge) + { + XA_DEBUG_ASSERT(m_cellDataOffsets.isEmpty()); + m_edges.push_back(edge); + } + + bool intersect(Vector2 v1, Vector2 v2, float epsilon) + { + const uint32_t edgeCount = m_edges.size(); + bool bruteForce = edgeCount <= 20; + if (!bruteForce && m_cellDataOffsets.isEmpty()) + bruteForce = !createGrid(); + if (bruteForce) { + for (uint32_t j = 0; j < edgeCount; j++) { + const uint32_t edge = m_edges[j]; + if (linesIntersect(v1, v2, edgePosition0(edge), edgePosition1(edge), epsilon)) + return true; + } + } else { + computePotentialEdges(v1, v2); + uint32_t prevEdge = UINT32_MAX; + for (uint32_t j = 0; j < m_potentialEdges.size(); j++) { + const uint32_t edge = m_potentialEdges[j]; + if (edge == prevEdge) + continue; + if (linesIntersect(v1, v2, edgePosition0(edge), edgePosition1(edge), epsilon)) + return true; + prevEdge = edge; + } + } + return false; + } + + // If edges is empty, checks for intersection with all edges in the grid. + bool intersect(float epsilon, ConstArrayView edges = ConstArrayView(), ConstArrayView ignoreEdges = ConstArrayView()) + { + bool bruteForce = m_edges.size() <= 20; + if (!bruteForce && m_cellDataOffsets.isEmpty()) + bruteForce = !createGrid(); + const uint32_t *edges1, *edges2 = nullptr; + uint32_t edges1Count, edges2Count = 0; + if (edges.length == 0) { + edges1 = m_edges.data(); + edges1Count = m_edges.size(); + } else { + edges1 = edges.data; + edges1Count = edges.length; + } + if (bruteForce) { + edges2 = m_edges.data(); + edges2Count = m_edges.size(); + } + for (uint32_t i = 0; i < edges1Count; i++) { + const uint32_t edge1 = edges1[i]; + const uint32_t edge1Vertex[2] = { vertexAt(meshEdgeIndex0(edge1)), vertexAt(meshEdgeIndex1(edge1)) }; + const Vector2 &edge1Position1 = m_positions[edge1Vertex[0]]; + const Vector2 &edge1Position2 = m_positions[edge1Vertex[1]]; + const Extents2 edge1Extents(edge1Position1, edge1Position2); + uint32_t j = 0; + if (bruteForce) { + // If checking against self, test each edge pair only once. + if (edges.length == 0) { + j = i + 1; + if (j == edges1Count) + break; + } + } else { + computePotentialEdges(edgePosition0(edge1), edgePosition1(edge1)); + edges2 = m_potentialEdges.data(); + edges2Count = m_potentialEdges.size(); + } + uint32_t prevEdge = UINT32_MAX; // Handle potential edges duplicates. + for (; j < edges2Count; j++) { + const uint32_t edge2 = edges2[j]; + if (edge1 == edge2) + continue; + if (edge2 == prevEdge) + continue; + prevEdge = edge2; + // Check if edge2 is ignored. + bool ignore = false; + for (uint32_t k = 0; k < ignoreEdges.length; k++) { + if (edge2 == ignoreEdges[k]) { + ignore = true; + break; + } + } + if (ignore) + continue; + const uint32_t edge2Vertex[2] = { vertexAt(meshEdgeIndex0(edge2)), vertexAt(meshEdgeIndex1(edge2)) }; + // Ignore connected edges, since they can't intersect (only overlap), and may be detected as false positives. + if (edge1Vertex[0] == edge2Vertex[0] || edge1Vertex[0] == edge2Vertex[1] || edge1Vertex[1] == edge2Vertex[0] || edge1Vertex[1] == edge2Vertex[1]) + continue; + const Vector2 &edge2Position1 = m_positions[edge2Vertex[0]]; + const Vector2 &edge2Position2 = m_positions[edge2Vertex[1]]; + if (!Extents2::intersect(edge1Extents, Extents2(edge2Position1, edge2Position2))) + continue; + if (linesIntersect(edge1Position1, edge1Position2, edge2Position1, edge2Position2, epsilon)) + return true; + } + } + return false; + } + +#if XA_DEBUG_EXPORT_BOUNDARY_GRID + void debugExport(const char *filename) + { + Array image; + image.resize(m_gridWidth * m_gridHeight * 3); + for (uint32_t y = 0; y < m_gridHeight; y++) { + for (uint32_t x = 0; x < m_gridWidth; x++) { + uint8_t *bgr = &image[(x + y * m_gridWidth) * 3]; + bgr[0] = bgr[1] = bgr[2] = 32; + uint32_t offset = m_cellDataOffsets[x + y * m_gridWidth]; + while (offset != UINT32_MAX) { + const uint32_t edge2 = m_cellData[offset]; + srand(edge2); + for (uint32_t i = 0; i < 3; i++) + bgr[i] = uint8_t(bgr[i] * 0.5f + (rand() % 255) * 0.5f); + offset = m_cellData[offset + 1]; + } + } + } + WriteTga(filename, image.data(), m_gridWidth, m_gridHeight); + } +#endif + +private: + bool createGrid() + { + // Compute edge extents. Min will be the grid origin. + const uint32_t edgeCount = m_edges.size(); + Extents2 edgeExtents; + edgeExtents.reset(); + for (uint32_t i = 0; i < edgeCount; i++) { + const uint32_t edge = m_edges[i]; + edgeExtents.add(edgePosition0(edge)); + edgeExtents.add(edgePosition1(edge)); + } + m_gridOrigin = edgeExtents.min; + // Size grid to approximately one edge per cell in the largest dimension. + const Vector2 extentsSize(edgeExtents.max - edgeExtents.min); + m_cellSize = max(extentsSize.x, extentsSize.y) / (float)clamp(edgeCount, 32u, 512u); + if (m_cellSize <= 0.0f) + return false; + m_gridWidth = uint32_t(ceilf(extentsSize.x / m_cellSize)); + m_gridHeight = uint32_t(ceilf(extentsSize.y / m_cellSize)); + if (m_gridWidth <= 1 || m_gridHeight <= 1) + return false; + // Insert edges into cells. + m_cellDataOffsets.resize(m_gridWidth * m_gridHeight); + for (uint32_t i = 0; i < m_cellDataOffsets.size(); i++) + m_cellDataOffsets[i] = UINT32_MAX; + m_cellData.clear(); + m_cellData.reserve(edgeCount * 2); + for (uint32_t i = 0; i < edgeCount; i++) { + const uint32_t edge = m_edges[i]; + traverse(edgePosition0(edge), edgePosition1(edge)); + XA_DEBUG_ASSERT(!m_traversedCellOffsets.isEmpty()); + for (uint32_t j = 0; j < m_traversedCellOffsets.size(); j++) { + const uint32_t cell = m_traversedCellOffsets[j]; + uint32_t offset = m_cellDataOffsets[cell]; + if (offset == UINT32_MAX) + m_cellDataOffsets[cell] = m_cellData.size(); + else { + for (;;) { + uint32_t &nextOffset = m_cellData[offset + 1]; + if (nextOffset == UINT32_MAX) { + nextOffset = m_cellData.size(); + break; + } + offset = nextOffset; + } + } + m_cellData.push_back(edge); + m_cellData.push_back(UINT32_MAX); + } + } + return true; + } + + void computePotentialEdges(Vector2 p1, Vector2 p2) + { + m_potentialEdges.clear(); + traverse(p1, p2); + for (uint32_t j = 0; j < m_traversedCellOffsets.size(); j++) { + const uint32_t cell = m_traversedCellOffsets[j]; + uint32_t offset = m_cellDataOffsets[cell]; + while (offset != UINT32_MAX) { + const uint32_t edge2 = m_cellData[offset]; + m_potentialEdges.push_back(edge2); + offset = m_cellData[offset + 1]; + } + } + if (m_potentialEdges.isEmpty()) + return; + insertionSort(m_potentialEdges.data(), m_potentialEdges.size()); + } + + // "A Fast Voxel Traversal Algorithm for Ray Tracing" + void traverse(Vector2 p1, Vector2 p2) + { + const Vector2 dir = p2 - p1; + const Vector2 normal = normalizeSafe(dir, Vector2(0.0f)); + const int stepX = dir.x >= 0 ? 1 : -1; + const int stepY = dir.y >= 0 ? 1 : -1; + const uint32_t firstCell[2] = { cellX(p1.x), cellY(p1.y) }; + const uint32_t lastCell[2] = { cellX(p2.x), cellY(p2.y) }; + float distToNextCellX; + if (stepX == 1) + distToNextCellX = (firstCell[0] + 1) * m_cellSize - (p1.x - m_gridOrigin.x); + else + distToNextCellX = (p1.x - m_gridOrigin.x) - firstCell[0] * m_cellSize; + float distToNextCellY; + if (stepY == 1) + distToNextCellY = (firstCell[1] + 1) * m_cellSize - (p1.y - m_gridOrigin.y); + else + distToNextCellY = (p1.y - m_gridOrigin.y) - firstCell[1] * m_cellSize; + float tMaxX, tMaxY, tDeltaX, tDeltaY; + if (normal.x > kEpsilon || normal.x < -kEpsilon) { + tMaxX = (distToNextCellX * stepX) / normal.x; + tDeltaX = (m_cellSize * stepX) / normal.x; + } + else + tMaxX = tDeltaX = FLT_MAX; + if (normal.y > kEpsilon || normal.y < -kEpsilon) { + tMaxY = (distToNextCellY * stepY) / normal.y; + tDeltaY = (m_cellSize * stepY) / normal.y; + } + else + tMaxY = tDeltaY = FLT_MAX; + m_traversedCellOffsets.clear(); + m_traversedCellOffsets.push_back(firstCell[0] + firstCell[1] * m_gridWidth); + uint32_t currentCell[2] = { firstCell[0], firstCell[1] }; + while (!(currentCell[0] == lastCell[0] && currentCell[1] == lastCell[1])) { + if (tMaxX < tMaxY) { + tMaxX += tDeltaX; + currentCell[0] += stepX; + } else { + tMaxY += tDeltaY; + currentCell[1] += stepY; + } + if (currentCell[0] >= m_gridWidth || currentCell[1] >= m_gridHeight) + break; + if (stepX == -1 && currentCell[0] < lastCell[0]) + break; + if (stepX == 1 && currentCell[0] > lastCell[0]) + break; + if (stepY == -1 && currentCell[1] < lastCell[1]) + break; + if (stepY == 1 && currentCell[1] > lastCell[1]) + break; + m_traversedCellOffsets.push_back(currentCell[0] + currentCell[1] * m_gridWidth); + } + } + + uint32_t cellX(float x) const + { + return min((uint32_t)max(0.0f, (x - m_gridOrigin.x) / m_cellSize), m_gridWidth - 1u); + } + + uint32_t cellY(float y) const + { + return min((uint32_t)max(0.0f, (y - m_gridOrigin.y) / m_cellSize), m_gridHeight - 1u); + } + + Vector2 edgePosition0(uint32_t edge) const + { + return m_positions[vertexAt(meshEdgeIndex0(edge))]; + } + + Vector2 edgePosition1(uint32_t edge) const + { + return m_positions[vertexAt(meshEdgeIndex1(edge))]; + } + + uint32_t vertexAt(uint32_t index) const + { + return m_indices.length > 0 ? m_indices[index] : index; + } + + Array m_edges; + ConstArrayView m_positions; + ConstArrayView m_indices; // Optional. Empty if unused. + float m_cellSize; + Vector2 m_gridOrigin; + uint32_t m_gridWidth, m_gridHeight; // in cells + Array m_cellDataOffsets; + Array m_cellData; + Array m_potentialEdges; + Array m_traversedCellOffsets; +}; + +struct UvMeshChart +{ + Array faces; + Array indices; + uint32_t material; +}; + +struct UvMesh +{ + UvMeshDecl decl; + BitArray faceIgnore; + Array faceMaterials; + Array indices; + Array texcoords; // Copied from input and never modified, UvMeshInstance::texcoords are. Used to restore UvMeshInstance::texcoords so packing can be run multiple times. + Array charts; + Array vertexToChartMap; +}; + +struct UvMeshInstance +{ + UvMesh *mesh; + Array texcoords; +}; + +/* + * Copyright (c) 2004-2010, Bruno Levy + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of the ALICE Project-Team nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + * If you modify this software, you should include a notice giving the + * name of the person performing the modification, the date of modification, + * and the reason for such modification. + * + * Contact: Bruno Levy + * + * levy@loria.fr + * + * ALICE Project + * LORIA, INRIA Lorraine, + * Campus Scientifique, BP 239 + * 54506 VANDOEUVRE LES NANCY CEDEX + * FRANCE + */ +namespace opennl { +#define NL_NEW(T) XA_ALLOC(MemTag::OpenNL, T) +#define NL_NEW_ARRAY(T,NB) XA_ALLOC_ARRAY(MemTag::OpenNL, T, NB) +#define NL_RENEW_ARRAY(T,x,NB) XA_REALLOC(MemTag::OpenNL, x, T, NB) +#define NL_DELETE(x) XA_FREE(x); x = nullptr +#define NL_DELETE_ARRAY(x) XA_FREE(x); x = nullptr +#define NL_CLEAR(x, T) memset(x, 0, sizeof(T)); +#define NL_CLEAR_ARRAY(T,x,NB) memset(x, 0, (size_t)(NB)*sizeof(T)) +#define NL_NEW_VECTOR(dim) XA_ALLOC_ARRAY(MemTag::OpenNL, double, dim) +#define NL_DELETE_VECTOR(ptr) XA_FREE(ptr) + +struct NLMatrixStruct; +typedef NLMatrixStruct * NLMatrix; +typedef void (*NLDestroyMatrixFunc)(NLMatrix M); +typedef void (*NLMultMatrixVectorFunc)(NLMatrix M, const double* x, double* y); + +#define NL_MATRIX_SPARSE_DYNAMIC 0x1001 +#define NL_MATRIX_CRS 0x1002 +#define NL_MATRIX_OTHER 0x1006 + +struct NLMatrixStruct +{ + uint32_t m; + uint32_t n; + uint32_t type; + NLDestroyMatrixFunc destroy_func; + NLMultMatrixVectorFunc mult_func; +}; + +/* Dynamic arrays for sparse row/columns */ + +struct NLCoeff +{ + uint32_t index; + double value; +}; + +struct NLRowColumn +{ + uint32_t size; + uint32_t capacity; + NLCoeff* coeff; +}; + +/* Compressed Row Storage */ + +struct NLCRSMatrix +{ + uint32_t m; + uint32_t n; + uint32_t type; + NLDestroyMatrixFunc destroy_func; + NLMultMatrixVectorFunc mult_func; + double* val; + uint32_t* rowptr; + uint32_t* colind; + uint32_t nslices; + uint32_t* sliceptr; +}; + +/* SparseMatrix data structure */ + +struct NLSparseMatrix +{ + uint32_t m; + uint32_t n; + uint32_t type; + NLDestroyMatrixFunc destroy_func; + NLMultMatrixVectorFunc mult_func; + uint32_t diag_size; + uint32_t diag_capacity; + NLRowColumn* row; + NLRowColumn* column; + double* diag; + uint32_t row_capacity; + uint32_t column_capacity; +}; + +/* NLContext data structure */ + +struct NLBufferBinding +{ + void* base_address; + uint32_t stride; +}; + +#define NL_BUFFER_ITEM(B,i) *(double*)((void*)((char*)((B).base_address)+((i)*(B).stride))) + +struct NLContext +{ + NLBufferBinding *variable_buffer; + double *variable_value; + bool *variable_is_locked; + uint32_t *variable_index; + uint32_t n; + NLMatrix M; + NLMatrix P; + NLMatrix B; + NLRowColumn af; + NLRowColumn al; + double *x; + double *b; + uint32_t nb_variables; + uint32_t nb_systems; + uint32_t current_row; + uint32_t max_iterations; + bool max_iterations_defined; + double threshold; + double omega; + uint32_t used_iterations; + double error; +}; + +static void nlDeleteMatrix(NLMatrix M) +{ + if (!M) + return; + M->destroy_func(M); + NL_DELETE(M); +} + +static void nlMultMatrixVector(NLMatrix M, const double* x, double* y) +{ + M->mult_func(M, x, y); +} + +static void nlRowColumnConstruct(NLRowColumn* c) +{ + c->size = 0; + c->capacity = 0; + c->coeff = nullptr; +} + +static void nlRowColumnDestroy(NLRowColumn* c) +{ + NL_DELETE_ARRAY(c->coeff); + c->size = 0; + c->capacity = 0; +} + +static void nlRowColumnGrow(NLRowColumn* c) +{ + if (c->capacity != 0) { + c->capacity = 2 * c->capacity; + c->coeff = NL_RENEW_ARRAY(NLCoeff, c->coeff, c->capacity); + } else { + c->capacity = 4; + c->coeff = NL_NEW_ARRAY(NLCoeff, c->capacity); + NL_CLEAR_ARRAY(NLCoeff, c->coeff, c->capacity); + } +} + +static void nlRowColumnAdd(NLRowColumn* c, uint32_t index, double value) +{ + for (uint32_t i = 0; i < c->size; i++) { + if (c->coeff[i].index == index) { + c->coeff[i].value += value; + return; + } + } + if (c->size == c->capacity) + nlRowColumnGrow(c); + c->coeff[c->size].index = index; + c->coeff[c->size].value = value; + c->size++; +} + +/* Does not check whether the index already exists */ +static void nlRowColumnAppend(NLRowColumn* c, uint32_t index, double value) +{ + if (c->size == c->capacity) + nlRowColumnGrow(c); + c->coeff[c->size].index = index; + c->coeff[c->size].value = value; + c->size++; +} + +static void nlRowColumnZero(NLRowColumn* c) +{ + c->size = 0; +} + +static void nlRowColumnClear(NLRowColumn* c) +{ + c->size = 0; + c->capacity = 0; + NL_DELETE_ARRAY(c->coeff); +} + +static int nlCoeffCompare(const void* p1, const void* p2) +{ + return (((NLCoeff*)(p2))->index < ((NLCoeff*)(p1))->index); +} + +static void nlRowColumnSort(NLRowColumn* c) +{ + qsort(c->coeff, c->size, sizeof(NLCoeff), nlCoeffCompare); +} + +/* CRSMatrix data structure */ + +static void nlCRSMatrixDestroy(NLCRSMatrix* M) +{ + NL_DELETE_ARRAY(M->val); + NL_DELETE_ARRAY(M->rowptr); + NL_DELETE_ARRAY(M->colind); + NL_DELETE_ARRAY(M->sliceptr); + M->m = 0; + M->n = 0; + M->nslices = 0; +} + +static void nlCRSMatrixMultSlice(NLCRSMatrix* M, const double* x, double* y, uint32_t Ibegin, uint32_t Iend) +{ + for (uint32_t i = Ibegin; i < Iend; ++i) { + double sum = 0.0; + for (uint32_t j = M->rowptr[i]; j < M->rowptr[i + 1]; ++j) + sum += M->val[j] * x[M->colind[j]]; + y[i] = sum; + } +} + +static void nlCRSMatrixMult(NLCRSMatrix* M, const double* x, double* y) +{ + int nslices = (int)(M->nslices); + for (int slice = 0; slice < nslices; ++slice) + nlCRSMatrixMultSlice(M, x, y, M->sliceptr[slice], M->sliceptr[slice + 1]); +} + +static void nlCRSMatrixConstruct(NLCRSMatrix* M, uint32_t m, uint32_t n, uint32_t nnz, uint32_t nslices) +{ + M->m = m; + M->n = n; + M->type = NL_MATRIX_CRS; + M->destroy_func = (NLDestroyMatrixFunc)nlCRSMatrixDestroy; + M->mult_func = (NLMultMatrixVectorFunc)nlCRSMatrixMult; + M->nslices = nslices; + M->val = NL_NEW_ARRAY(double, nnz); + NL_CLEAR_ARRAY(double, M->val, nnz); + M->rowptr = NL_NEW_ARRAY(uint32_t, m + 1); + NL_CLEAR_ARRAY(uint32_t, M->rowptr, m + 1); + M->colind = NL_NEW_ARRAY(uint32_t, nnz); + NL_CLEAR_ARRAY(uint32_t, M->colind, nnz); + M->sliceptr = NL_NEW_ARRAY(uint32_t, nslices + 1); + NL_CLEAR_ARRAY(uint32_t, M->sliceptr, nslices + 1); +} + +/* SparseMatrix data structure */ + +static void nlSparseMatrixDestroyRowColumns(NLSparseMatrix* M) +{ + for (uint32_t i = 0; i < M->m; i++) + nlRowColumnDestroy(&(M->row[i])); + NL_DELETE_ARRAY(M->row); +} + +static void nlSparseMatrixDestroy(NLSparseMatrix* M) +{ + XA_DEBUG_ASSERT(M->type == NL_MATRIX_SPARSE_DYNAMIC); + nlSparseMatrixDestroyRowColumns(M); + NL_DELETE_ARRAY(M->diag); +} + +static void nlSparseMatrixAdd(NLSparseMatrix* M, uint32_t i, uint32_t j, double value) +{ + XA_DEBUG_ASSERT(i >= 0 && i <= M->m - 1); + XA_DEBUG_ASSERT(j >= 0 && j <= M->n - 1); + if (i == j) + M->diag[i] += value; + nlRowColumnAdd(&(M->row[i]), j, value); +} + +/* Returns the number of non-zero coefficients */ +static uint32_t nlSparseMatrixNNZ(NLSparseMatrix* M) +{ + uint32_t nnz = 0; + for (uint32_t i = 0; i < M->m; i++) + nnz += M->row[i].size; + return nnz; +} + +static void nlSparseMatrixSort(NLSparseMatrix* M) +{ + for (uint32_t i = 0; i < M->m; i++) + nlRowColumnSort(&(M->row[i])); +} + +/* SparseMatrix x Vector routines, internal helper routines */ + +static void nlSparseMatrix_mult_rows(NLSparseMatrix* A, const double* x, double* y) +{ + /* + * Note: OpenMP does not like unsigned ints + * (causes some floating point exceptions), + * therefore I use here signed ints for all + * indices. + */ + int m = (int)(A->m); + NLCoeff* c = nullptr; + NLRowColumn* Ri = nullptr; + for (int i = 0; i < m; i++) { + Ri = &(A->row[i]); + y[i] = 0; + for (int ij = 0; ij < (int)(Ri->size); ij++) { + c = &(Ri->coeff[ij]); + y[i] += c->value * x[c->index]; + } + } +} + +static void nlSparseMatrixMult(NLSparseMatrix* A, const double* x, double* y) +{ + XA_DEBUG_ASSERT(A->type == NL_MATRIX_SPARSE_DYNAMIC); + nlSparseMatrix_mult_rows(A, x, y); +} + +static void nlSparseMatrixConstruct(NLSparseMatrix* M, uint32_t m, uint32_t n) +{ + M->m = m; + M->n = n; + M->type = NL_MATRIX_SPARSE_DYNAMIC; + M->destroy_func = (NLDestroyMatrixFunc)nlSparseMatrixDestroy; + M->mult_func = (NLMultMatrixVectorFunc)nlSparseMatrixMult; + M->row = NL_NEW_ARRAY(NLRowColumn, m); + NL_CLEAR_ARRAY(NLRowColumn, M->row, m); + M->row_capacity = m; + for (uint32_t i = 0; i < n; i++) + nlRowColumnConstruct(&(M->row[i])); + M->row_capacity = 0; + M->column = nullptr; + M->column_capacity = 0; + M->diag_size = min(m, n); + M->diag_capacity = M->diag_size; + M->diag = NL_NEW_ARRAY(double, M->diag_size); + NL_CLEAR_ARRAY(double, M->diag, M->diag_size); +} + +static NLMatrix nlCRSMatrixNewFromSparseMatrix(NLSparseMatrix* M) +{ + uint32_t nnz = nlSparseMatrixNNZ(M); + uint32_t nslices = 8; /* TODO: get number of cores */ + uint32_t slice, cur_bound, cur_NNZ, cur_row; + uint32_t k; + uint32_t slice_size = nnz / nslices; + NLCRSMatrix* CRS = NL_NEW(NLCRSMatrix); + NL_CLEAR(CRS, NLCRSMatrix); + nlCRSMatrixConstruct(CRS, M->m, M->n, nnz, nslices); + nlSparseMatrixSort(M); + /* Convert matrix to CRS format */ + k = 0; + for (uint32_t i = 0; i < M->m; ++i) { + NLRowColumn* Ri = &(M->row[i]); + CRS->rowptr[i] = k; + for (uint32_t ij = 0; ij < Ri->size; ij++) { + NLCoeff* c = &(Ri->coeff[ij]); + CRS->val[k] = c->value; + CRS->colind[k] = c->index; + ++k; + } + } + CRS->rowptr[M->m] = k; + /* Create "slices" to be used by parallel sparse matrix vector product */ + if (CRS->sliceptr) { + cur_bound = slice_size; + cur_NNZ = 0; + cur_row = 0; + CRS->sliceptr[0] = 0; + for (slice = 1; slice < nslices; ++slice) { + while (cur_NNZ < cur_bound && cur_row < M->m) { + cur_NNZ += CRS->rowptr[cur_row + 1] - CRS->rowptr[cur_row]; + ++cur_row; + } + CRS->sliceptr[slice] = cur_row; + cur_bound += slice_size; + } + CRS->sliceptr[nslices] = M->m; + } + return (NLMatrix)CRS; +} + +static void nlMatrixCompress(NLMatrix* M) +{ + NLMatrix CRS = nullptr; + if ((*M)->type != NL_MATRIX_SPARSE_DYNAMIC) + return; + CRS = nlCRSMatrixNewFromSparseMatrix((NLSparseMatrix*)*M); + nlDeleteMatrix(*M); + *M = CRS; +} + +static NLContext *nlNewContext() +{ + NLContext* result = NL_NEW(NLContext); + NL_CLEAR(result, NLContext); + result->max_iterations = 100; + result->threshold = 1e-6; + result->omega = 1.5; + result->nb_systems = 1; + return result; +} + +static void nlDeleteContext(NLContext *context) +{ + nlDeleteMatrix(context->M); + context->M = nullptr; + nlDeleteMatrix(context->P); + context->P = nullptr; + nlDeleteMatrix(context->B); + context->B = nullptr; + nlRowColumnDestroy(&context->af); + nlRowColumnDestroy(&context->al); + NL_DELETE_ARRAY(context->variable_value); + NL_DELETE_ARRAY(context->variable_buffer); + NL_DELETE_ARRAY(context->variable_is_locked); + NL_DELETE_ARRAY(context->variable_index); + NL_DELETE_ARRAY(context->x); + NL_DELETE_ARRAY(context->b); + NL_DELETE(context); +} + +static double ddot(int n, const double *x, const double *y) +{ + double sum = 0.0; + for (int i = 0; i < n; i++) + sum += x[i] * y[i]; + return sum; +} + +static void daxpy(int n, double a, const double *x, double *y) +{ + for (int i = 0; i < n; i++) + y[i] = a * x[i] + y[i]; +} + +static void dscal(int n, double a, double *x) +{ + for (int i = 0; i < n; i++) + x[i] *= a; +} + +/* + * The implementation of the solvers is inspired by + * the lsolver library, by Christian Badura, available from: + * http://www.mathematik.uni-freiburg.de + * /IAM/Research/projectskr/lin_solver/ + * + * About the Conjugate Gradient, details can be found in: + * Ashby, Manteuffel, Saylor + * A taxononmy for conjugate gradient methods + * SIAM J Numer Anal 27, 1542-1568 (1990) + * + * This version is completely abstract, the same code can be used for + * CPU/GPU, dense matrix / sparse matrix etc... + * Abstraction is realized through: + * - Abstract matrix interface (NLMatrix), that can implement different + * versions of matrix x vector product (CPU/GPU, sparse/dense ...) + */ + +static uint32_t nlSolveSystem_PRE_CG(NLMatrix M, NLMatrix P, double* b, double* x, double eps, uint32_t max_iter, double *sq_bnorm, double *sq_rnorm) +{ + int N = (int)M->n; + double* r = NL_NEW_VECTOR(N); + double* d = NL_NEW_VECTOR(N); + double* h = NL_NEW_VECTOR(N); + double *Ad = h; + uint32_t its = 0; + double rh, alpha, beta; + double b_square = ddot(N, b, b); + double err = eps * eps*b_square; + double curr_err; + nlMultMatrixVector(M, x, r); + daxpy(N, -1., b, r); + nlMultMatrixVector(P, r, d); + memcpy(h, d, N * sizeof(double)); + rh = ddot(N, r, h); + curr_err = ddot(N, r, r); + while (curr_err > err && its < max_iter) { + nlMultMatrixVector(M, d, Ad); + alpha = rh / ddot(N, d, Ad); + daxpy(N, -alpha, d, x); + daxpy(N, -alpha, Ad, r); + nlMultMatrixVector(P, r, h); + beta = 1. / rh; + rh = ddot(N, r, h); + beta *= rh; + dscal(N, beta, d); + daxpy(N, 1., h, d); + ++its; + curr_err = ddot(N, r, r); + } + NL_DELETE_VECTOR(r); + NL_DELETE_VECTOR(d); + NL_DELETE_VECTOR(h); + *sq_bnorm = b_square; + *sq_rnorm = curr_err; + return its; +} + +static uint32_t nlSolveSystemIterative(NLContext *context, NLMatrix M, NLMatrix P, double* b_in, double* x_in, double eps, uint32_t max_iter) +{ + uint32_t result = 0; + double rnorm = 0.0; + double bnorm = 0.0; + double* b = b_in; + double* x = x_in; + XA_DEBUG_ASSERT(M->m == M->n); + double sq_bnorm, sq_rnorm; + result = nlSolveSystem_PRE_CG(M, P, b, x, eps, max_iter, &sq_bnorm, &sq_rnorm); + /* Get residual norm and rhs norm */ + bnorm = sqrt(sq_bnorm); + rnorm = sqrt(sq_rnorm); + if (bnorm == 0.0) + context->error = rnorm; + else + context->error = rnorm / bnorm; + context->used_iterations = result; + return result; +} + +static bool nlSolveIterative(NLContext *context) +{ + double* b = context->b; + double* x = context->x; + uint32_t n = context->n; + NLMatrix M = context->M; + NLMatrix P = context->P; + for (uint32_t k = 0; k < context->nb_systems; ++k) { + nlSolveSystemIterative(context, M, P, b, x, context->threshold, context->max_iterations); + b += n; + x += n; + } + return true; +} + +struct NLJacobiPreconditioner +{ + uint32_t m; + uint32_t n; + uint32_t type; + NLDestroyMatrixFunc destroy_func; + NLMultMatrixVectorFunc mult_func; + double* diag_inv; +}; + +static void nlJacobiPreconditionerDestroy(NLJacobiPreconditioner* M) +{ + NL_DELETE_ARRAY(M->diag_inv); +} + +static void nlJacobiPreconditionerMult(NLJacobiPreconditioner* M, const double* x, double* y) +{ + for (uint32_t i = 0; i < M->n; ++i) + y[i] = x[i] * M->diag_inv[i]; +} + +static NLMatrix nlNewJacobiPreconditioner(NLMatrix M_in) +{ + NLSparseMatrix* M = nullptr; + NLJacobiPreconditioner* result = nullptr; + XA_DEBUG_ASSERT(M_in->type == NL_MATRIX_SPARSE_DYNAMIC); + XA_DEBUG_ASSERT(M_in->m == M_in->n); + M = (NLSparseMatrix*)M_in; + result = NL_NEW(NLJacobiPreconditioner); + NL_CLEAR(result, NLJacobiPreconditioner); + result->m = M->m; + result->n = M->n; + result->type = NL_MATRIX_OTHER; + result->destroy_func = (NLDestroyMatrixFunc)nlJacobiPreconditionerDestroy; + result->mult_func = (NLMultMatrixVectorFunc)nlJacobiPreconditionerMult; + result->diag_inv = NL_NEW_ARRAY(double, M->n); + NL_CLEAR_ARRAY(double, result->diag_inv, M->n); + for (uint32_t i = 0; i < M->n; ++i) + result->diag_inv[i] = (M->diag[i] == 0.0) ? 1.0 : 1.0 / M->diag[i]; + return (NLMatrix)result; +} + +#define NL_NB_VARIABLES 0x101 +#define NL_MAX_ITERATIONS 0x103 + +static void nlSolverParameteri(NLContext *context, uint32_t pname, int param) +{ + if (pname == NL_NB_VARIABLES) { + XA_DEBUG_ASSERT(param > 0); + context->nb_variables = (uint32_t)param; + } else if (pname == NL_MAX_ITERATIONS) { + XA_DEBUG_ASSERT(param > 0); + context->max_iterations = (uint32_t)param; + context->max_iterations_defined = true; + } +} + +static void nlSetVariable(NLContext *context, uint32_t index, double value) +{ + XA_DEBUG_ASSERT(index >= 0 && index <= context->nb_variables - 1); + NL_BUFFER_ITEM(context->variable_buffer[0], index) = value; +} + +static double nlGetVariable(NLContext *context, uint32_t index) +{ + XA_DEBUG_ASSERT(index >= 0 && index <= context->nb_variables - 1); + return NL_BUFFER_ITEM(context->variable_buffer[0], index); +} + +static void nlLockVariable(NLContext *context, uint32_t index) +{ + XA_DEBUG_ASSERT(index >= 0 && index <= context->nb_variables - 1); + context->variable_is_locked[index] = true; +} + +static void nlVariablesToVector(NLContext *context) +{ + uint32_t n = context->n; + XA_DEBUG_ASSERT(context->x); + for (uint32_t k = 0; k < context->nb_systems; ++k) { + for (uint32_t i = 0; i < context->nb_variables; ++i) { + if (!context->variable_is_locked[i]) { + uint32_t index = context->variable_index[i]; + XA_DEBUG_ASSERT(index < context->n); + double value = NL_BUFFER_ITEM(context->variable_buffer[k], i); + context->x[index + k * n] = value; + } + } + } +} + +static void nlVectorToVariables(NLContext *context) +{ + uint32_t n = context->n; + XA_DEBUG_ASSERT(context->x); + for (uint32_t k = 0; k < context->nb_systems; ++k) { + for (uint32_t i = 0; i < context->nb_variables; ++i) { + if (!context->variable_is_locked[i]) { + uint32_t index = context->variable_index[i]; + XA_DEBUG_ASSERT(index < context->n); + double value = context->x[index + k * n]; + NL_BUFFER_ITEM(context->variable_buffer[k], i) = value; + } + } + } +} + +static void nlCoefficient(NLContext *context, uint32_t index, double value) +{ + XA_DEBUG_ASSERT(index >= 0 && index <= context->nb_variables - 1); + if (context->variable_is_locked[index]) { + /* + * Note: in al, indices are NLvariable indices, + * within [0..nb_variables-1] + */ + nlRowColumnAppend(&(context->al), index, value); + } else { + /* + * Note: in af, indices are system indices, + * within [0..n-1] + */ + nlRowColumnAppend(&(context->af), context->variable_index[index], value); + } +} + +#define NL_SYSTEM 0x0 +#define NL_MATRIX 0x1 +#define NL_ROW 0x2 + +static void nlBegin(NLContext *context, uint32_t prim) +{ + if (prim == NL_SYSTEM) { + XA_DEBUG_ASSERT(context->nb_variables > 0); + context->variable_buffer = NL_NEW_ARRAY(NLBufferBinding, context->nb_systems); + NL_CLEAR_ARRAY(NLBufferBinding, context->variable_buffer, context->nb_systems); + context->variable_value = NL_NEW_ARRAY(double, context->nb_variables * context->nb_systems); + NL_CLEAR_ARRAY(double, context->variable_value, context->nb_variables * context->nb_systems); + for (uint32_t k = 0; k < context->nb_systems; ++k) { + context->variable_buffer[k].base_address = + context->variable_value + + k * context->nb_variables; + context->variable_buffer[k].stride = sizeof(double); + } + context->variable_is_locked = NL_NEW_ARRAY(bool, context->nb_variables); + NL_CLEAR_ARRAY(bool, context->variable_is_locked, context->nb_variables); + context->variable_index = NL_NEW_ARRAY(uint32_t, context->nb_variables); + NL_CLEAR_ARRAY(uint32_t, context->variable_index, context->nb_variables); + } else if (prim == NL_MATRIX) { + if (context->M) + return; + uint32_t n = 0; + for (uint32_t i = 0; i < context->nb_variables; i++) { + if (!context->variable_is_locked[i]) { + context->variable_index[i] = n; + n++; + } else + context->variable_index[i] = (uint32_t)~0; + } + context->n = n; + if (!context->max_iterations_defined) + context->max_iterations = n * 5; + context->M = (NLMatrix)(NL_NEW(NLSparseMatrix)); + NL_CLEAR(context->M, NLSparseMatrix); + nlSparseMatrixConstruct((NLSparseMatrix*)(context->M), n, n); + context->x = NL_NEW_ARRAY(double, n*context->nb_systems); + NL_CLEAR_ARRAY(double, context->x, n*context->nb_systems); + context->b = NL_NEW_ARRAY(double, n*context->nb_systems); + NL_CLEAR_ARRAY(double, context->b, n*context->nb_systems); + nlVariablesToVector(context); + nlRowColumnConstruct(&context->af); + nlRowColumnConstruct(&context->al); + context->current_row = 0; + } else if (prim == NL_ROW) { + nlRowColumnZero(&context->af); + nlRowColumnZero(&context->al); + } +} + +static void nlEnd(NLContext *context, uint32_t prim) +{ + if (prim == NL_MATRIX) { + nlRowColumnClear(&context->af); + nlRowColumnClear(&context->al); + } else if (prim == NL_ROW) { + NLRowColumn* af = &context->af; + NLRowColumn* al = &context->al; + NLSparseMatrix* M = (NLSparseMatrix*)context->M; + double* b = context->b; + uint32_t nf = af->size; + uint32_t nl = al->size; + uint32_t n = context->n; + double S; + /* + * least_squares : we want to solve + * A'A x = A'b + */ + for (uint32_t i = 0; i < nf; i++) { + for (uint32_t j = 0; j < nf; j++) { + nlSparseMatrixAdd(M, af->coeff[i].index, af->coeff[j].index, af->coeff[i].value * af->coeff[j].value); + } + } + for (uint32_t k = 0; k < context->nb_systems; ++k) { + S = 0.0; + for (uint32_t jj = 0; jj < nl; ++jj) { + uint32_t j = al->coeff[jj].index; + S += al->coeff[jj].value * NL_BUFFER_ITEM(context->variable_buffer[k], j); + } + for (uint32_t jj = 0; jj < nf; jj++) + b[k*n + af->coeff[jj].index] -= af->coeff[jj].value * S; + } + context->current_row++; + } +} + +static bool nlSolve(NLContext *context) +{ + nlDeleteMatrix(context->P); + context->P = nlNewJacobiPreconditioner(context->M); + nlMatrixCompress(&context->M); + bool result = nlSolveIterative(context); + nlVectorToVariables(context); + return result; +} +} // namespace opennl + +namespace raster { +class ClippedTriangle +{ +public: + ClippedTriangle(const Vector2 &a, const Vector2 &b, const Vector2 &c) + { + m_numVertices = 3; + m_activeVertexBuffer = 0; + m_verticesA[0] = a; + m_verticesA[1] = b; + m_verticesA[2] = c; + m_vertexBuffers[0] = m_verticesA; + m_vertexBuffers[1] = m_verticesB; + m_area = 0; + } + + void clipHorizontalPlane(float offset, float clipdirection) + { + Vector2 *v = m_vertexBuffers[m_activeVertexBuffer]; + m_activeVertexBuffer ^= 1; + Vector2 *v2 = m_vertexBuffers[m_activeVertexBuffer]; + v[m_numVertices] = v[0]; + float dy2, dy1 = offset - v[0].y; + int dy2in, dy1in = clipdirection * dy1 >= 0; + uint32_t p = 0; + for (uint32_t k = 0; k < m_numVertices; k++) { + dy2 = offset - v[k + 1].y; + dy2in = clipdirection * dy2 >= 0; + if (dy1in) v2[p++] = v[k]; + if ( dy1in + dy2in == 1 ) { // not both in/out + float dx = v[k + 1].x - v[k].x; + float dy = v[k + 1].y - v[k].y; + v2[p++] = Vector2(v[k].x + dy1 * (dx / dy), offset); + } + dy1 = dy2; + dy1in = dy2in; + } + m_numVertices = p; + } + + void clipVerticalPlane(float offset, float clipdirection) + { + Vector2 *v = m_vertexBuffers[m_activeVertexBuffer]; + m_activeVertexBuffer ^= 1; + Vector2 *v2 = m_vertexBuffers[m_activeVertexBuffer]; + v[m_numVertices] = v[0]; + float dx2, dx1 = offset - v[0].x; + int dx2in, dx1in = clipdirection * dx1 >= 0; + uint32_t p = 0; + for (uint32_t k = 0; k < m_numVertices; k++) { + dx2 = offset - v[k + 1].x; + dx2in = clipdirection * dx2 >= 0; + if (dx1in) v2[p++] = v[k]; + if ( dx1in + dx2in == 1 ) { // not both in/out + float dx = v[k + 1].x - v[k].x; + float dy = v[k + 1].y - v[k].y; + v2[p++] = Vector2(offset, v[k].y + dx1 * (dy / dx)); + } + dx1 = dx2; + dx1in = dx2in; + } + m_numVertices = p; + } + + void computeArea() + { + Vector2 *v = m_vertexBuffers[m_activeVertexBuffer]; + v[m_numVertices] = v[0]; + m_area = 0; + for (uint32_t k = 0; k < m_numVertices; k++) { + // http://local.wasp.uwa.edu.au/~pbourke/geometry/polyarea/ + float f = v[k].x * v[k + 1].y - v[k + 1].x * v[k].y; + m_area += f; + } + m_area = 0.5f * fabsf(m_area); + } + + void clipAABox(float x0, float y0, float x1, float y1) + { + clipVerticalPlane(x0, -1); + clipHorizontalPlane(y0, -1); + clipVerticalPlane(x1, 1); + clipHorizontalPlane(y1, 1); + computeArea(); + } + + float area() const + { + return m_area; + } + +private: + Vector2 m_verticesA[7 + 1]; + Vector2 m_verticesB[7 + 1]; + Vector2 *m_vertexBuffers[2]; + uint32_t m_numVertices; + uint32_t m_activeVertexBuffer; + float m_area; +}; + +/// A callback to sample the environment. Return false to terminate rasterization. +typedef bool (*SamplingCallback)(void *param, int x, int y); + +/// A triangle for rasterization. +struct Triangle +{ + Triangle(const Vector2 &_v0, const Vector2 &_v1, const Vector2 &_v2) : v1(_v0), v2(_v2), v3(_v1), n1(0.0f), n2(0.0f), n3(0.0f) + { + // make sure every triangle is front facing. + flipBackface(); + // Compute deltas. + if (isValid()) + computeUnitInwardNormals(); + } + + bool isValid() + { + const Vector2 e0 = v3 - v1; + const Vector2 e1 = v2 - v1; + const float area = e0.y * e1.x - e1.y * e0.x; + return area != 0.0f; + } + + // extents has to be multiple of BK_SIZE!! + bool drawAA(const Vector2 &extents, SamplingCallback cb, void *param) + { + const float PX_INSIDE = 1.0f/sqrtf(2.0f); + const float PX_OUTSIDE = -1.0f/sqrtf(2.0f); + const float BK_SIZE = 8; + const float BK_INSIDE = sqrtf(BK_SIZE*BK_SIZE/2.0f); + const float BK_OUTSIDE = -sqrtf(BK_SIZE*BK_SIZE/2.0f); + // Bounding rectangle + float minx = floorf(max(min3(v1.x, v2.x, v3.x), 0.0f)); + float miny = floorf(max(min3(v1.y, v2.y, v3.y), 0.0f)); + float maxx = ceilf( min(max3(v1.x, v2.x, v3.x), extents.x - 1.0f)); + float maxy = ceilf( min(max3(v1.y, v2.y, v3.y), extents.y - 1.0f)); + // There's no reason to align the blocks to the viewport, instead we align them to the origin of the triangle bounds. + minx = floorf(minx); + miny = floorf(miny); + //minx = (float)(((int)minx) & (~((int)BK_SIZE - 1))); // align to blocksize (we don't need to worry about blocks partially out of viewport) + //miny = (float)(((int)miny) & (~((int)BK_SIZE - 1))); + minx += 0.5; + miny += 0.5; // sampling at texel centers! + maxx += 0.5; + maxy += 0.5; + // Half-edge constants + float C1 = n1.x * (-v1.x) + n1.y * (-v1.y); + float C2 = n2.x * (-v2.x) + n2.y * (-v2.y); + float C3 = n3.x * (-v3.x) + n3.y * (-v3.y); + // Loop through blocks + for (float y0 = miny; y0 <= maxy; y0 += BK_SIZE) { + for (float x0 = minx; x0 <= maxx; x0 += BK_SIZE) { + // Corners of block + float xc = (x0 + (BK_SIZE - 1) / 2.0f); + float yc = (y0 + (BK_SIZE - 1) / 2.0f); + // Evaluate half-space functions + float aC = C1 + n1.x * xc + n1.y * yc; + float bC = C2 + n2.x * xc + n2.y * yc; + float cC = C3 + n3.x * xc + n3.y * yc; + // Skip block when outside an edge + if ( (aC <= BK_OUTSIDE) || (bC <= BK_OUTSIDE) || (cC <= BK_OUTSIDE) ) continue; + // Accept whole block when totally covered + if ( (aC >= BK_INSIDE) && (bC >= BK_INSIDE) && (cC >= BK_INSIDE) ) { + for (float y = y0; y < y0 + BK_SIZE; y++) { + for (float x = x0; x < x0 + BK_SIZE; x++) { + if (!cb(param, (int)x, (int)y)) + return false; + } + } + } else { // Partially covered block + float CY1 = C1 + n1.x * x0 + n1.y * y0; + float CY2 = C2 + n2.x * x0 + n2.y * y0; + float CY3 = C3 + n3.x * x0 + n3.y * y0; + for (float y = y0; y < y0 + BK_SIZE; y++) { // @@ This is not clipping to scissor rectangle correctly. + float CX1 = CY1; + float CX2 = CY2; + float CX3 = CY3; + for (float x = x0; x < x0 + BK_SIZE; x++) { // @@ This is not clipping to scissor rectangle correctly. + if (CX1 >= PX_INSIDE && CX2 >= PX_INSIDE && CX3 >= PX_INSIDE) { + if (!cb(param, (int)x, (int)y)) + return false; + } else if ((CX1 >= PX_OUTSIDE) && (CX2 >= PX_OUTSIDE) && (CX3 >= PX_OUTSIDE)) { + // triangle partially covers pixel. do clipping. + ClippedTriangle ct(v1 - Vector2(x, y), v2 - Vector2(x, y), v3 - Vector2(x, y)); + ct.clipAABox(-0.5, -0.5, 0.5, 0.5); + if (ct.area() > 0.0f) { + if (!cb(param, (int)x, (int)y)) + return false; + } + } + CX1 += n1.x; + CX2 += n2.x; + CX3 += n3.x; + } + CY1 += n1.y; + CY2 += n2.y; + CY3 += n3.y; + } + } + } + } + return true; + } + +private: + void flipBackface() + { + // check if triangle is backfacing, if so, swap two vertices + if ( ((v3.x - v1.x) * (v2.y - v1.y) - (v3.y - v1.y) * (v2.x - v1.x)) < 0 ) { + Vector2 hv = v1; + v1 = v2; + v2 = hv; // swap pos + } + } + + // compute unit inward normals for each edge. + void computeUnitInwardNormals() + { + n1 = v1 - v2; + n1 = Vector2(-n1.y, n1.x); + n1 = n1 * (1.0f / sqrtf(dot(n1, n1))); + n2 = v2 - v3; + n2 = Vector2(-n2.y, n2.x); + n2 = n2 * (1.0f / sqrtf(dot(n2, n2))); + n3 = v3 - v1; + n3 = Vector2(-n3.y, n3.x); + n3 = n3 * (1.0f / sqrtf(dot(n3, n3))); + } + + // Vertices. + Vector2 v1, v2, v3; + Vector2 n1, n2, n3; // unit inward normals +}; + +// Process the given triangle. Returns false if rasterization was interrupted by the callback. +static bool drawTriangle(const Vector2 &extents, const Vector2 v[3], SamplingCallback cb, void *param) +{ + Triangle tri(v[0], v[1], v[2]); + // @@ It would be nice to have a conservative drawing mode that enlarges the triangle extents by one texel and is able to handle degenerate triangles. + // @@ Maybe the simplest thing to do would be raster triangle edges. + if (tri.isValid()) + return tri.drawAA(extents, cb, param); + return true; +} + +} // namespace raster + +namespace segment { + +// - Insertion is o(n) +// - Smallest element goes at the end, so that popping it is o(1). +struct CostQueue +{ + CostQueue(uint32_t size = UINT32_MAX) : m_maxSize(size), m_pairs(MemTag::SegmentAtlasChartCandidates) {} + + float peekCost() const + { + return m_pairs.back().cost; + } + + uint32_t peekFace() const + { + return m_pairs.back().face; + } + + void push(float cost, uint32_t face) + { + const Pair p = { cost, face }; + if (m_pairs.isEmpty() || cost < peekCost()) + m_pairs.push_back(p); + else { + uint32_t i = 0; + const uint32_t count = m_pairs.size(); + for (; i < count; i++) { + if (m_pairs[i].cost < cost) + break; + } + m_pairs.insertAt(i, p); + if (m_pairs.size() > m_maxSize) + m_pairs.removeAt(0); + } + } + + uint32_t pop() + { + XA_DEBUG_ASSERT(!m_pairs.isEmpty()); + uint32_t f = m_pairs.back().face; + m_pairs.pop_back(); + return f; + } + + XA_INLINE void clear() + { + m_pairs.clear(); + } + + XA_INLINE uint32_t count() const + { + return m_pairs.size(); + } + +private: + const uint32_t m_maxSize; + + struct Pair + { + float cost; + uint32_t face; + }; + + Array m_pairs; +}; + +struct AtlasData +{ + ChartOptions options; + const Mesh *mesh = nullptr; + Array edgeDihedralAngles; + Array edgeLengths; + Array faceAreas; + Array faceUvAreas; // Can be negative. + Array faceNormals; + BitArray isFaceInChart; + + AtlasData() : edgeDihedralAngles(MemTag::SegmentAtlasMeshData), edgeLengths(MemTag::SegmentAtlasMeshData), faceAreas(MemTag::SegmentAtlasMeshData), faceNormals(MemTag::SegmentAtlasMeshData) {} + + void compute() + { + const uint32_t faceCount = mesh->faceCount(); + const uint32_t edgeCount = mesh->edgeCount(); + edgeDihedralAngles.resize(edgeCount); + edgeLengths.resize(edgeCount); + faceAreas.resize(faceCount); + if (options.useInputMeshUvs) + faceUvAreas.resize(faceCount); + faceNormals.resize(faceCount); + isFaceInChart.resize(faceCount); + isFaceInChart.zeroOutMemory(); + for (uint32_t f = 0; f < faceCount; f++) { + for (uint32_t i = 0; i < 3; i++) { + const uint32_t edge = f * 3 + i; + const Vector3 &p0 = mesh->position(mesh->vertexAt(meshEdgeIndex0(edge))); + const Vector3 &p1 = mesh->position(mesh->vertexAt(meshEdgeIndex1(edge))); + edgeLengths[edge] = length(p1 - p0); + XA_DEBUG_ASSERT(edgeLengths[edge] > 0.0f); + } + faceAreas[f] = mesh->computeFaceArea(f); + XA_DEBUG_ASSERT(faceAreas[f] > 0.0f); + if (options.useInputMeshUvs) + faceUvAreas[f] = mesh->computeFaceParametricArea(f); + faceNormals[f] = mesh->computeFaceNormal(f); + } + for (uint32_t face = 0; face < faceCount; face++) { + for (uint32_t i = 0; i < 3; i++) { + const uint32_t edge = face * 3 + i; + const uint32_t oedge = mesh->oppositeEdge(edge); + if (oedge == UINT32_MAX) + edgeDihedralAngles[edge] = FLT_MAX; + else { + const uint32_t oface = meshEdgeFace(oedge); + edgeDihedralAngles[edge] = edgeDihedralAngles[oedge] = dot(faceNormals[face], faceNormals[oface]); + } + } + } + } +}; + +// If MeshDecl::vertexUvData is set on input meshes, find charts by floodfilling faces in world/model space without crossing UV seams. +struct OriginalUvCharts +{ + OriginalUvCharts(AtlasData &data) : m_data(data) {} + uint32_t chartCount() const { return m_charts.size(); } + const Basis &chartBasis(uint32_t chartIndex) const { return m_chartBasis[chartIndex]; } + + ConstArrayView chartFaces(uint32_t chartIndex) const + { + const Chart &chart = m_charts[chartIndex]; + return ConstArrayView(&m_chartFaces[chart.firstFace], chart.faceCount); + } + + void compute() + { + m_charts.clear(); + m_chartFaces.clear(); + const Mesh *mesh = m_data.mesh; + const uint32_t faceCount = mesh->faceCount(); + for (uint32_t f = 0; f < faceCount; f++) { + if (m_data.isFaceInChart.get(f)) + continue; + if (isZero(m_data.faceUvAreas[f], kAreaEpsilon)) + continue; // Face must have valid UVs. + // Found an unassigned face, create a new chart. + Chart chart; + chart.firstFace = m_chartFaces.size(); + chart.faceCount = 1; + m_chartFaces.push_back(f); + m_data.isFaceInChart.set(f); + floodfillFaces(chart); + m_charts.push_back(chart); + } + // Compute basis for each chart. + m_chartBasis.resize(m_charts.size()); + for (uint32_t c = 0; c < m_charts.size(); c++) + { + const Chart &chart = m_charts[c]; + m_tempPoints.resize(chart.faceCount * 3); + for (uint32_t f = 0; f < chart.faceCount; f++) { + const uint32_t face = m_chartFaces[chart.firstFace + f]; + for (uint32_t i = 0; i < 3; i++) + m_tempPoints[f * 3 + i] = m_data.mesh->position(m_data.mesh->vertexAt(face * 3 + i)); + } + Fit::computeBasis(m_tempPoints, &m_chartBasis[c]); + } + } + +private: + struct Chart + { + uint32_t firstFace, faceCount; + }; + + void floodfillFaces(Chart &chart) + { + const bool isFaceAreaNegative = m_data.faceUvAreas[m_chartFaces[chart.firstFace]] < 0.0f; + for (;;) { + bool newFaceAdded = false; + const uint32_t faceCount = chart.faceCount; + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t sourceFace = m_chartFaces[chart.firstFace + f]; + for (Mesh::FaceEdgeIterator edgeIt(m_data.mesh, sourceFace); !edgeIt.isDone(); edgeIt.advance()) { + const uint32_t face = edgeIt.oppositeFace(); + if (face == UINT32_MAX) + continue; // Boundary edge. + if (m_data.isFaceInChart.get(face)) + continue; // Already assigned to a chart. + if (isZero(m_data.faceUvAreas[face], kAreaEpsilon)) + continue; // Face must have valid UVs. + if ((m_data.faceUvAreas[face] < 0.0f) != isFaceAreaNegative) + continue; // Face winding is opposite of the first chart face. + const Vector2 &uv0 = m_data.mesh->texcoord(edgeIt.vertex0()); + const Vector2 &uv1 = m_data.mesh->texcoord(edgeIt.vertex1()); + const Vector2 &ouv0 = m_data.mesh->texcoord(m_data.mesh->vertexAt(meshEdgeIndex0(edgeIt.oppositeEdge()))); + const Vector2 &ouv1 = m_data.mesh->texcoord(m_data.mesh->vertexAt(meshEdgeIndex1(edgeIt.oppositeEdge()))); + if (!equal(uv0, ouv1, m_data.mesh->epsilon()) || !equal(uv1, ouv0, m_data.mesh->epsilon())) + continue; // UVs must match exactly. + m_chartFaces.push_back(face); + chart.faceCount++; + m_data.isFaceInChart.set(face); + newFaceAdded = true; + } + } + if (!newFaceAdded) + break; + } + } + + AtlasData &m_data; + Array m_charts; + Array m_chartBasis; + Array m_chartFaces; + Array m_tempPoints; +}; + +#if XA_DEBUG_EXPORT_OBJ_PLANAR_REGIONS +static uint32_t s_planarRegionsCurrentRegion; +static uint32_t s_planarRegionsCurrentVertex; +#endif + +struct PlanarCharts +{ + PlanarCharts(AtlasData &data) : m_data(data), m_nextRegionFace(MemTag::SegmentAtlasPlanarRegions), m_faceToRegionId(MemTag::SegmentAtlasPlanarRegions) {} + const Basis &chartBasis(uint32_t chartIndex) const { return m_chartBasis[chartIndex]; } + uint32_t chartCount() const { return m_charts.size(); } + + ConstArrayView chartFaces(uint32_t chartIndex) const + { + const Chart &chart = m_charts[chartIndex]; + return ConstArrayView(&m_chartFaces[chart.firstFace], chart.faceCount); + } + + uint32_t regionIdFromFace(uint32_t face) const { return m_faceToRegionId[face]; } + uint32_t nextRegionFace(uint32_t face) const { return m_nextRegionFace[face]; } + float regionArea(uint32_t region) const { return m_regionAreas[region]; } + + void compute() + { + const uint32_t faceCount = m_data.mesh->faceCount(); + // Precompute regions of coplanar incident faces. + m_regionFirstFace.clear(); + m_nextRegionFace.resize(faceCount); + m_faceToRegionId.resize(faceCount); + for (uint32_t f = 0; f < faceCount; f++) { + m_nextRegionFace[f] = f; + m_faceToRegionId[f] = UINT32_MAX; + } + Array faceStack; + faceStack.reserve(min(faceCount, 16u)); + uint32_t regionCount = 0; + for (uint32_t f = 0; f < faceCount; f++) { + if (m_nextRegionFace[f] != f) + continue; // Already assigned. + if (m_data.isFaceInChart.get(f)) + continue; // Already in a chart. + faceStack.clear(); + faceStack.push_back(f); + for (;;) { + if (faceStack.isEmpty()) + break; + const uint32_t face = faceStack.back(); + m_faceToRegionId[face] = regionCount; + faceStack.pop_back(); + for (Mesh::FaceEdgeIterator it(m_data.mesh, face); !it.isDone(); it.advance()) { + const uint32_t oface = it.oppositeFace(); + if (it.isBoundary()) + continue; + if (m_nextRegionFace[oface] != oface) + continue; // Already assigned. + if (m_data.isFaceInChart.get(oface)) + continue; // Already in a chart. + if (!equal(dot(m_data.faceNormals[face], m_data.faceNormals[oface]), 1.0f, kEpsilon)) + continue; // Not coplanar. + const uint32_t next = m_nextRegionFace[face]; + m_nextRegionFace[face] = oface; + m_nextRegionFace[oface] = next; + m_faceToRegionId[oface] = regionCount; + faceStack.push_back(oface); + } + } + m_regionFirstFace.push_back(f); + regionCount++; + } +#if XA_DEBUG_EXPORT_OBJ_PLANAR_REGIONS + static std::mutex s_mutex; + { + std::lock_guard lock(s_mutex); + FILE *file; + XA_FOPEN(file, "debug_mesh_planar_regions.obj", s_planarRegionsCurrentRegion == 0 ? "w" : "a"); + if (file) { + m_data.mesh->writeObjVertices(file); + fprintf(file, "s off\n"); + for (uint32_t i = 0; i < regionCount; i++) { + fprintf(file, "o region%u\n", s_planarRegionsCurrentRegion); + for (uint32_t j = 0; j < faceCount; j++) { + if (m_faceToRegionId[j] == i) + m_data.mesh->writeObjFace(file, j, s_planarRegionsCurrentVertex); + } + s_planarRegionsCurrentRegion++; + } + s_planarRegionsCurrentVertex += m_data.mesh->vertexCount(); + fclose(file); + } + } +#endif + // Precompute planar region areas. + m_regionAreas.resize(regionCount); + m_regionAreas.zeroOutMemory(); + for (uint32_t f = 0; f < faceCount; f++) { + if (m_faceToRegionId[f] == UINT32_MAX) + continue; + m_regionAreas[m_faceToRegionId[f]] += m_data.faceAreas[f]; + } + // Create charts from suitable planar regions. + // The dihedral angle of all boundary edges must be >= 90 degrees. + m_charts.clear(); + m_chartFaces.clear(); + for (uint32_t region = 0; region < regionCount; region++) { + const uint32_t firstRegionFace = m_regionFirstFace[region]; + uint32_t face = firstRegionFace; + bool createChart = true; + do { + for (Mesh::FaceEdgeIterator it(m_data.mesh, face); !it.isDone(); it.advance()) { + if (it.isBoundary()) + continue; // Ignore mesh boundary edges. + const uint32_t oface = it.oppositeFace(); + if (m_faceToRegionId[oface] == region) + continue; // Ignore internal edges. + const float angle = m_data.edgeDihedralAngles[it.edge()]; + if (angle > 0.0f && angle < FLT_MAX) { // FLT_MAX on boundaries. + createChart = false; + break; + } + } + if (!createChart) + break; + face = m_nextRegionFace[face]; + } + while (face != firstRegionFace); + // Create a chart. + if (createChart) { + Chart chart; + chart.firstFace = m_chartFaces.size(); + chart.faceCount = 0; + face = firstRegionFace; + do { + m_data.isFaceInChart.set(face); + m_chartFaces.push_back(face); + chart.faceCount++; + face = m_nextRegionFace[face]; + } + while (face != firstRegionFace); + m_charts.push_back(chart); + } + } + // Compute basis for each chart using the first face normal (all faces have the same normal). + m_chartBasis.resize(m_charts.size()); + for (uint32_t c = 0; c < m_charts.size(); c++) + { + const uint32_t face = m_chartFaces[m_charts[c].firstFace]; + Basis &basis = m_chartBasis[c]; + basis.normal = m_data.faceNormals[face]; + basis.tangent = Basis::computeTangent(basis.normal); + basis.bitangent = Basis::computeBitangent(basis.normal, basis.tangent); + } + } + +private: + struct Chart + { + uint32_t firstFace, faceCount; + }; + + AtlasData &m_data; + Array m_regionFirstFace; + Array m_nextRegionFace; + Array m_faceToRegionId; + Array m_regionAreas; + Array m_charts; + Array m_chartFaces; + Array m_chartBasis; +}; + +struct ClusteredCharts +{ + ClusteredCharts(AtlasData &data, const PlanarCharts &planarCharts) : m_data(data), m_planarCharts(planarCharts), m_texcoords(MemTag::SegmentAtlasMeshData), m_bestTriangles(10), m_placingSeeds(false) {} + + ~ClusteredCharts() + { + const uint32_t chartCount = m_charts.size(); + for (uint32_t i = 0; i < chartCount; i++) { + m_charts[i]->~Chart(); + XA_FREE(m_charts[i]); + } + } + + uint32_t chartCount() const { return m_charts.size(); } + ConstArrayView chartFaces(uint32_t chartIndex) const { return m_charts[chartIndex]->faces; } + const Basis &chartBasis(uint32_t chartIndex) const { return m_charts[chartIndex]->basis; } + + void compute() + { + const uint32_t faceCount = m_data.mesh->faceCount(); + m_facesLeft = 0; + for (uint32_t i = 0; i < faceCount; i++) { + if (!m_data.isFaceInChart.get(i)) + m_facesLeft++; + } + const uint32_t chartCount = m_charts.size(); + for (uint32_t i = 0; i < chartCount; i++) { + m_charts[i]->~Chart(); + XA_FREE(m_charts[i]); + } + m_charts.clear(); + m_faceCharts.resize(faceCount); + m_faceCharts.fill(-1); + m_texcoords.resize(faceCount * 3); + if (m_facesLeft == 0) + return; + // Create initial charts greedely. + placeSeeds(m_data.options.maxCost * 0.5f); + if (m_data.options.maxIterations == 0) { + XA_DEBUG_ASSERT(m_facesLeft == 0); + return; + } + relocateSeeds(); + resetCharts(); + // Restart process growing charts in parallel. + uint32_t iteration = 0; + for (;;) { + growCharts(m_data.options.maxCost); + // When charts cannot grow more: fill holes, merge charts, relocate seeds and start new iteration. + fillHoles(m_data.options.maxCost * 0.5f); +#if XA_MERGE_CHARTS + mergeCharts(); +#endif + if (++iteration == m_data.options.maxIterations) + break; + if (!relocateSeeds()) + break; + resetCharts(); + } + // Make sure no holes are left! + XA_DEBUG_ASSERT(m_facesLeft == 0); + } + +private: + struct Chart + { + Chart() : faces(MemTag::SegmentAtlasChartFaces) {} + + int id = -1; + Basis basis; // Best fit normal. + float area = 0.0f; + float boundaryLength = 0.0f; + Vector3 centroidSum = Vector3(0.0f); // Sum of chart face centroids. + Vector3 centroid = Vector3(0.0f); // Average centroid of chart faces. + Array faces; + Array failedPlanarRegions; + CostQueue candidates; + uint32_t seed; + }; + + void placeSeeds(float threshold) + { + XA_PROFILE_START(clusteredChartsPlaceSeeds) + m_placingSeeds = true; + // Instead of using a predefiened number of seeds: + // - Add seeds one by one, growing chart until a certain treshold. + // - Undo charts and restart growing process. + // @@ How can we give preference to faces far from sharp features as in the LSCM paper? + // - those points can be found using a simple flood filling algorithm. + // - how do we weight the probabilities? + while (m_facesLeft > 0) + createChart(threshold); + m_placingSeeds = false; + XA_PROFILE_END(clusteredChartsPlaceSeeds) + } + + // Returns true if any of the charts can grow more. + void growCharts(float threshold) + { + XA_PROFILE_START(clusteredChartsGrow) + for (;;) { + if (m_facesLeft == 0) + break; + // Get the single best candidate out of the chart best candidates. + uint32_t bestFace = UINT32_MAX, bestChart = UINT32_MAX; + float lowestCost = FLT_MAX; + for (uint32_t i = 0; i < m_charts.size(); i++) { + Chart *chart = m_charts[i]; + // Get the best candidate from the chart. + // Cleanup any best candidates that have been claimed by another chart. + uint32_t face = UINT32_MAX; + float cost = FLT_MAX; + for (;;) { + if (chart->candidates.count() == 0) + break; + cost = chart->candidates.peekCost(); + face = chart->candidates.peekFace(); + if (!m_data.isFaceInChart.get(face)) + break; + else { + // Face belongs to another chart. Pop from queue so the next best candidate can be retrieved. + chart->candidates.pop(); + face = UINT32_MAX; + } + } + if (face == UINT32_MAX) + continue; // No candidates for this chart. + // See if best candidate overall. + if (cost < lowestCost) { + lowestCost = cost; + bestFace = face; + bestChart = i; + } + } + if (bestFace == UINT32_MAX || lowestCost > threshold) + break; + Chart *chart = m_charts[bestChart]; + chart->candidates.pop(); // Pop the selected candidate from the queue. + if (!addFaceToChart(chart, bestFace)) + chart->failedPlanarRegions.push_back(m_planarCharts.regionIdFromFace(bestFace)); + } + XA_PROFILE_END(clusteredChartsGrow) + } + + void resetCharts() + { + XA_PROFILE_START(clusteredChartsReset) + const uint32_t faceCount = m_data.mesh->faceCount(); + for (uint32_t i = 0; i < faceCount; i++) { + if (m_faceCharts[i] != -1) + m_data.isFaceInChart.unset(i); + m_faceCharts[i] = -1; + } + m_facesLeft = 0; + for (uint32_t i = 0; i < faceCount; i++) { + if (!m_data.isFaceInChart.get(i)) + m_facesLeft++; + } + const uint32_t chartCount = m_charts.size(); + for (uint32_t i = 0; i < chartCount; i++) { + Chart *chart = m_charts[i]; + chart->area = 0.0f; + chart->boundaryLength = 0.0f; + chart->basis.normal = Vector3(0.0f); + chart->basis.tangent = Vector3(0.0f); + chart->basis.bitangent = Vector3(0.0f); + chart->centroidSum = Vector3(0.0f); + chart->centroid = Vector3(0.0f); + chart->faces.clear(); + chart->candidates.clear(); + chart->failedPlanarRegions.clear(); + addFaceToChart(chart, chart->seed); + } + XA_PROFILE_END(clusteredChartsReset) + } + + bool relocateSeeds() + { + XA_PROFILE_START(clusteredChartsRelocateSeeds) + bool anySeedChanged = false; + const uint32_t chartCount = m_charts.size(); + for (uint32_t i = 0; i < chartCount; i++) { + if (relocateSeed(m_charts[i])) { + anySeedChanged = true; + } + } + XA_PROFILE_END(clusteredChartsRelocateSeeds) + return anySeedChanged; + } + + void fillHoles(float threshold) + { + XA_PROFILE_START(clusteredChartsFillHoles) + while (m_facesLeft > 0) + createChart(threshold); + XA_PROFILE_END(clusteredChartsFillHoles) + } + +#if XA_MERGE_CHARTS + void mergeCharts() + { + XA_PROFILE_START(clusteredChartsMerge) + const uint32_t chartCount = m_charts.size(); + // Merge charts progressively until there's none left to merge. + for (;;) { + bool merged = false; + for (int c = chartCount - 1; c >= 0; c--) { + Chart *chart = m_charts[c]; + if (chart == nullptr) + continue; + float externalBoundaryLength = 0.0f; + m_sharedBoundaryLengths.resize(chartCount); + m_sharedBoundaryLengths.zeroOutMemory(); + m_sharedBoundaryLengthsNoSeams.resize(chartCount); + m_sharedBoundaryLengthsNoSeams.zeroOutMemory(); + m_sharedBoundaryEdgeCountNoSeams.resize(chartCount); + m_sharedBoundaryEdgeCountNoSeams.zeroOutMemory(); + const uint32_t faceCount = chart->faces.size(); + for (uint32_t i = 0; i < faceCount; i++) { + const uint32_t f = chart->faces[i]; + for (Mesh::FaceEdgeIterator it(m_data.mesh, f); !it.isDone(); it.advance()) { + const float l = m_data.edgeLengths[it.edge()]; + if (it.isBoundary()) { + externalBoundaryLength += l; + } else { + const int neighborChart = m_faceCharts[it.oppositeFace()]; + if (neighborChart == -1) + externalBoundaryLength += l; + else if (m_charts[neighborChart] != chart) { + if ((it.isSeam() && (isNormalSeam(it.edge()) || it.isTextureSeam()))) { + externalBoundaryLength += l; + } else { + m_sharedBoundaryLengths[neighborChart] += l; + } + m_sharedBoundaryLengthsNoSeams[neighborChart] += l; + m_sharedBoundaryEdgeCountNoSeams[neighborChart]++; + } + } + } + } + for (int cc = chartCount - 1; cc >= 0; cc--) { + if (cc == c) + continue; + Chart *chart2 = m_charts[cc]; + if (chart2 == nullptr) + continue; + // Must share a boundary. + if (m_sharedBoundaryLengths[cc] <= 0.0f) + continue; + // Compare proxies. + if (dot(chart2->basis.normal, chart->basis.normal) < XA_MERGE_CHARTS_MIN_NORMAL_DEVIATION) + continue; + // Obey max chart area and boundary length. + if (m_data.options.maxChartArea > 0.0f && chart->area + chart2->area > m_data.options.maxChartArea) + continue; + if (m_data.options.maxBoundaryLength > 0.0f && chart->boundaryLength + chart2->boundaryLength - m_sharedBoundaryLengthsNoSeams[cc] > m_data.options.maxBoundaryLength) + continue; + // Merge if chart2 has a single face. + // chart1 must have more than 1 face. + // chart2 area must be <= 10% of chart1 area. + if (m_sharedBoundaryLengthsNoSeams[cc] > 0.0f && chart->faces.size() > 1 && chart2->faces.size() == 1 && chart2->area <= chart->area * 0.1f) + goto merge; + // Merge if chart2 has two faces (probably a quad), and chart1 bounds at least 2 of its edges. + if (chart2->faces.size() == 2 && m_sharedBoundaryEdgeCountNoSeams[cc] >= 2) + goto merge; + // Merge if chart2 is wholely inside chart1, ignoring seams. + if (m_sharedBoundaryLengthsNoSeams[cc] > 0.0f && equal(m_sharedBoundaryLengthsNoSeams[cc], chart2->boundaryLength, kEpsilon)) + goto merge; + if (m_sharedBoundaryLengths[cc] > 0.2f * max(0.0f, chart->boundaryLength - externalBoundaryLength) || + m_sharedBoundaryLengths[cc] > 0.75f * chart2->boundaryLength) + goto merge; + continue; + merge: + if (!mergeChart(chart, chart2, m_sharedBoundaryLengthsNoSeams[cc])) + continue; + merged = true; + break; + } + if (merged) + break; + } + if (!merged) + break; + } + // Remove deleted charts. + for (int c = 0; c < int32_t(m_charts.size()); /*do not increment if removed*/) { + if (m_charts[c] == nullptr) { + m_charts.removeAt(c); + // Update m_faceCharts. + const uint32_t faceCount = m_faceCharts.size(); + for (uint32_t i = 0; i < faceCount; i++) { + XA_DEBUG_ASSERT(m_faceCharts[i] != c); + XA_DEBUG_ASSERT(m_faceCharts[i] <= int32_t(m_charts.size())); + if (m_faceCharts[i] > c) { + m_faceCharts[i]--; + } + } + } else { + m_charts[c]->id = c; + c++; + } + } + XA_PROFILE_END(clusteredChartsMerge) + } +#endif + +private: + void createChart(float threshold) + { + Chart *chart = XA_NEW(MemTag::Default, Chart); + chart->id = (int)m_charts.size(); + m_charts.push_back(chart); + // Pick a face not used by any chart yet, belonging to the largest planar region. + chart->seed = 0; + float largestArea = 0.0f; + for (uint32_t f = 0; f < m_data.mesh->faceCount(); f++) { + if (m_data.isFaceInChart.get(f)) + continue; + const float area = m_planarCharts.regionArea(m_planarCharts.regionIdFromFace(f)); + if (area > largestArea) { + largestArea = area; + chart->seed = f; + } + } + addFaceToChart(chart, chart->seed); + // Grow the chart as much as possible within the given threshold. + for (;;) { + if (chart->candidates.count() == 0 || chart->candidates.peekCost() > threshold) + break; + const uint32_t f = chart->candidates.pop(); + if (m_data.isFaceInChart.get(f)) + continue; + if (!addFaceToChart(chart, f)) { + chart->failedPlanarRegions.push_back(m_planarCharts.regionIdFromFace(f)); + continue; + } + } + } + + bool isChartBoundaryEdge(const Chart *chart, uint32_t edge) const + { + const uint32_t oppositeEdge = m_data.mesh->oppositeEdge(edge); + const uint32_t oppositeFace = meshEdgeFace(oppositeEdge); + return oppositeEdge == UINT32_MAX || m_faceCharts[oppositeFace] != chart->id; + } + + bool computeChartBasis(Chart *chart, Basis *basis) + { + const uint32_t faceCount = chart->faces.size(); + m_tempPoints.resize(chart->faces.size() * 3); + for (uint32_t i = 0; i < faceCount; i++) { + const uint32_t f = chart->faces[i]; + for (uint32_t j = 0; j < 3; j++) + m_tempPoints[i * 3 + j] = m_data.mesh->position(m_data.mesh->vertexAt(f * 3 + j)); + } + return Fit::computeBasis(m_tempPoints, basis); + } + + bool isFaceFlipped(uint32_t face) const + { + const Vector2 &v1 = m_texcoords[face * 3 + 0]; + const Vector2 &v2 = m_texcoords[face * 3 + 1]; + const Vector2 &v3 = m_texcoords[face * 3 + 2]; + const float parametricArea = ((v2.x - v1.x) * (v3.y - v1.y) - (v3.x - v1.x) * (v2.y - v1.y)) * 0.5f; + return parametricArea < 0.0f; + } + + void parameterizeChart(const Chart *chart) + { + const uint32_t faceCount = chart->faces.size(); + for (uint32_t i = 0; i < faceCount; i++) { + const uint32_t face = chart->faces[i]; + for (uint32_t j = 0; j < 3; j++) { + const uint32_t offset = face * 3 + j; + const Vector3 &pos = m_data.mesh->position(m_data.mesh->vertexAt(offset)); + m_texcoords[offset] = Vector2(dot(chart->basis.tangent, pos), dot(chart->basis.bitangent, pos)); + } + } + } + + // m_faceCharts for the chart faces must be set to the chart ID. Needed to compute boundary edges. + bool isChartParameterizationValid(const Chart *chart) + { + const uint32_t faceCount = chart->faces.size(); + // Check for flipped faces in the parameterization. OK if all are flipped. + uint32_t flippedFaceCount = 0; + for (uint32_t i = 0; i < faceCount; i++) { + if (isFaceFlipped(chart->faces[i])) + flippedFaceCount++; + } + if (flippedFaceCount != 0 && flippedFaceCount != faceCount) + return false; + // Check for boundary intersection in the parameterization. + XA_PROFILE_START(clusteredChartsPlaceSeedsBoundaryIntersection) + XA_PROFILE_START(clusteredChartsGrowBoundaryIntersection) + m_boundaryGrid.reset(m_texcoords); + for (uint32_t i = 0; i < faceCount; i++) { + const uint32_t f = chart->faces[i]; + for (uint32_t j = 0; j < 3; j++) { + const uint32_t edge = f * 3 + j; + if (isChartBoundaryEdge(chart, edge)) + m_boundaryGrid.append(edge); + } + } + const bool intersection = m_boundaryGrid.intersect(m_data.mesh->epsilon()); +#if XA_PROFILE + if (m_placingSeeds) + XA_PROFILE_END(clusteredChartsPlaceSeedsBoundaryIntersection) + else + XA_PROFILE_END(clusteredChartsGrowBoundaryIntersection) +#endif + if (intersection) + return false; + return true; + } + + bool addFaceToChart(Chart *chart, uint32_t face) + { + XA_DEBUG_ASSERT(!m_data.isFaceInChart.get(face)); + const uint32_t oldFaceCount = chart->faces.size(); + const bool firstFace = oldFaceCount == 0; + // Append the face and any coplanar connected faces to the chart faces array. + chart->faces.push_back(face); + uint32_t coplanarFace = m_planarCharts.nextRegionFace(face); + while (coplanarFace != face) { + XA_DEBUG_ASSERT(!m_data.isFaceInChart.get(coplanarFace)); + chart->faces.push_back(coplanarFace); + coplanarFace = m_planarCharts.nextRegionFace(coplanarFace); + } + const uint32_t faceCount = chart->faces.size(); + // Compute basis. + Basis basis; + if (firstFace) { + // Use the first face normal. + // Use any edge as the tangent vector. + basis.normal = m_data.faceNormals[face]; + basis.tangent = normalize(m_data.mesh->position(m_data.mesh->vertexAt(face * 3 + 0)) - m_data.mesh->position(m_data.mesh->vertexAt(face * 3 + 1))); + basis.bitangent = cross(basis.normal, basis.tangent); + } else { + // Use best fit normal. + if (!computeChartBasis(chart, &basis)) { + chart->faces.resize(oldFaceCount); + return false; + } + if (dot(basis.normal, m_data.faceNormals[face]) < 0.0f) // Flip normal if oriented in the wrong direction. + basis.normal = -basis.normal; + } + if (!firstFace) { + // Compute orthogonal parameterization and check that it is valid. + parameterizeChart(chart); + for (uint32_t i = oldFaceCount; i < faceCount; i++) + m_faceCharts[chart->faces[i]] = chart->id; + if (!isChartParameterizationValid(chart)) { + for (uint32_t i = oldFaceCount; i < faceCount; i++) + m_faceCharts[chart->faces[i]] = -1; + chart->faces.resize(oldFaceCount); + return false; + } + } + // Add face(s) to chart. + chart->basis = basis; + chart->area = computeArea(chart, face); + chart->boundaryLength = computeBoundaryLength(chart, face); + for (uint32_t i = oldFaceCount; i < faceCount; i++) { + const uint32_t f = chart->faces[i]; + m_faceCharts[f] = chart->id; + m_facesLeft--; + m_data.isFaceInChart.set(f); + chart->centroidSum += m_data.mesh->computeFaceCenter(f); + } + chart->centroid = chart->centroidSum / float(chart->faces.size()); + // Refresh candidates. + chart->candidates.clear(); + for (uint32_t i = 0; i < faceCount; i++) { + // Traverse neighboring faces, add the ones that do not belong to any chart yet. + const uint32_t f = chart->faces[i]; + for (uint32_t j = 0; j < 3; j++) { + const uint32_t edge = f * 3 + j; + const uint32_t oedge = m_data.mesh->oppositeEdge(edge); + if (oedge == UINT32_MAX) + continue; // Boundary edge. + const uint32_t oface = meshEdgeFace(oedge); + if (m_data.isFaceInChart.get(oface)) + continue; // Face belongs to another chart. + if (chart->failedPlanarRegions.contains(m_planarCharts.regionIdFromFace(oface))) + continue; // Failed to add this faces planar region to the chart before. + const float cost = computeCost(chart, oface); + if (cost < FLT_MAX) + chart->candidates.push(cost, oface); + } + } + return true; + } + + // Returns true if the seed has changed. + bool relocateSeed(Chart *chart) + { + // Find the first N triangles that fit the proxy best. + const uint32_t faceCount = chart->faces.size(); + m_bestTriangles.clear(); + for (uint32_t i = 0; i < faceCount; i++) { + const float cost = computeNormalDeviationMetric(chart, chart->faces[i]); + m_bestTriangles.push(cost, chart->faces[i]); + } + // Of those, choose the most central triangle. + uint32_t mostCentral = 0; + float minDistance = FLT_MAX; + for (;;) { + if (m_bestTriangles.count() == 0) + break; + const uint32_t face = m_bestTriangles.pop(); + Vector3 faceCentroid = m_data.mesh->computeFaceCenter(face); + const float distance = length(chart->centroid - faceCentroid); + if (distance < minDistance) { + minDistance = distance; + mostCentral = face; + } + } + XA_DEBUG_ASSERT(minDistance < FLT_MAX); + if (mostCentral == chart->seed) + return false; + chart->seed = mostCentral; + return true; + } + + // Cost is combined metrics * weights. + float computeCost(Chart *chart, uint32_t face) const + { + // Estimate boundary length and area: + const float newChartArea = computeArea(chart, face); + const float newBoundaryLength = computeBoundaryLength(chart, face); + // Enforce limits strictly: + if (m_data.options.maxChartArea > 0.0f && newChartArea > m_data.options.maxChartArea) + return FLT_MAX; + if (m_data.options.maxBoundaryLength > 0.0f && newBoundaryLength > m_data.options.maxBoundaryLength) + return FLT_MAX; + // Compute metrics. + float cost = 0.0f; + const float normalDeviation = computeNormalDeviationMetric(chart, face); + if (normalDeviation >= 0.707f) // ~75 degrees + return FLT_MAX; + cost += m_data.options.normalDeviationWeight * normalDeviation; + // Penalize faces that cross seams, reward faces that close seams or reach boundaries. + // Make sure normal seams are fully respected: + const float normalSeam = computeNormalSeamMetric(chart, face); + if (m_data.options.normalSeamWeight >= 1000.0f && normalSeam > 0.0f) + return FLT_MAX; + cost += m_data.options.normalSeamWeight * normalSeam; + cost += m_data.options.roundnessWeight * computeRoundnessMetric(chart, newBoundaryLength, newChartArea); + cost += m_data.options.straightnessWeight * computeStraightnessMetric(chart, face); + cost += m_data.options.textureSeamWeight * computeTextureSeamMetric(chart, face); + //float R = evaluateCompletenessMetric(chart, face); + //float D = evaluateDihedralAngleMetric(chart, face); + // @@ Add a metric based on local dihedral angle. + // @@ Tweaking the normal and texture seam metrics. + // - Cause more impedance. Never cross 90 degree edges. + XA_DEBUG_ASSERT(isFinite(cost)); + return cost; + } + + // Returns a value in [0-1]. + // 0 if face normal is coplanar to the chart's best fit normal. + // 1 if face normal is perpendicular. + float computeNormalDeviationMetric(Chart *chart, uint32_t face) const + { + // All faces in coplanar regions have the same normal, can use any face. + const Vector3 faceNormal = m_data.faceNormals[face]; + // Use plane fitting metric for now: + return min(1.0f - dot(faceNormal, chart->basis.normal), 1.0f); // @@ normal deviations should be weighted by face area + } + + float computeRoundnessMetric(Chart *chart, float newBoundaryLength, float newChartArea) const + { + const float oldRoundness = square(chart->boundaryLength) / chart->area; + const float newRoundness = square(newBoundaryLength) / newChartArea; + return 1.0f - oldRoundness / newRoundness; + } + + float computeStraightnessMetric(Chart *chart, uint32_t firstFace) const + { + float l_out = 0.0f; // Length of firstFace planar region boundary that doesn't border the chart. + float l_in = 0.0f; // Length that does border the chart. + const uint32_t planarRegionId = m_planarCharts.regionIdFromFace(firstFace); + uint32_t face = firstFace; + for (;;) { + for (Mesh::FaceEdgeIterator it(m_data.mesh, face); !it.isDone(); it.advance()) { + const float l = m_data.edgeLengths[it.edge()]; + if (it.isBoundary()) { + l_out += l; + } else if (m_planarCharts.regionIdFromFace(it.oppositeFace()) != planarRegionId) { + if (m_faceCharts[it.oppositeFace()] != chart->id) + l_out += l; + else + l_in += l; + } + } + face = m_planarCharts.nextRegionFace(face); + if (face == firstFace) + break; + } +#if 1 + float ratio = (l_out - l_in) / (l_out + l_in); + return min(ratio, 0.0f); // Only use the straightness metric to close gaps. +#else + return 1.0f - l_in / l_out; +#endif + } + + bool isNormalSeam(uint32_t edge) const + { + const uint32_t oppositeEdge = m_data.mesh->oppositeEdge(edge); + if (oppositeEdge == UINT32_MAX) + return false; // boundary edge + if (m_data.mesh->flags() & MeshFlags::HasNormals) { + const uint32_t v0 = m_data.mesh->vertexAt(meshEdgeIndex0(edge)); + const uint32_t v1 = m_data.mesh->vertexAt(meshEdgeIndex1(edge)); + const uint32_t ov0 = m_data.mesh->vertexAt(meshEdgeIndex0(oppositeEdge)); + const uint32_t ov1 = m_data.mesh->vertexAt(meshEdgeIndex1(oppositeEdge)); + if (v0 == ov1 && v1 == ov0) + return false; + return !equal(m_data.mesh->normal(v0), m_data.mesh->normal(ov1), kNormalEpsilon) || !equal(m_data.mesh->normal(v1), m_data.mesh->normal(ov0), kNormalEpsilon); + } + const uint32_t f0 = meshEdgeFace(edge); + const uint32_t f1 = meshEdgeFace(oppositeEdge); + if (m_planarCharts.regionIdFromFace(f0) == m_planarCharts.regionIdFromFace(f1)) + return false; + return !equal(m_data.faceNormals[f0], m_data.faceNormals[f1], kNormalEpsilon); + } + + float computeNormalSeamMetric(Chart *chart, uint32_t firstFace) const + { + float seamFactor = 0.0f, totalLength = 0.0f; + uint32_t face = firstFace; + for (;;) { + for (Mesh::FaceEdgeIterator it(m_data.mesh, face); !it.isDone(); it.advance()) { + if (it.isBoundary()) + continue; + if (m_faceCharts[it.oppositeFace()] != chart->id) + continue; + float l = m_data.edgeLengths[it.edge()]; + totalLength += l; + if (!it.isSeam()) + continue; + // Make sure it's a normal seam. + if (isNormalSeam(it.edge())) { + float d; + if (m_data.mesh->flags() & MeshFlags::HasNormals) { + const Vector3 &n0 = m_data.mesh->normal(it.vertex0()); + const Vector3 &n1 = m_data.mesh->normal(it.vertex1()); + const Vector3 &on0 = m_data.mesh->normal(m_data.mesh->vertexAt(meshEdgeIndex0(it.oppositeEdge()))); + const Vector3 &on1 = m_data.mesh->normal(m_data.mesh->vertexAt(meshEdgeIndex1(it.oppositeEdge()))); + const float d0 = clamp(dot(n0, on1), 0.0f, 1.0f); + const float d1 = clamp(dot(n1, on0), 0.0f, 1.0f); + d = (d0 + d1) * 0.5f; + } else { + d = clamp(dot(m_data.faceNormals[face], m_data.faceNormals[meshEdgeFace(it.oppositeEdge())]), 0.0f, 1.0f); + } + l *= 1 - d; + seamFactor += l; + } + } + face = m_planarCharts.nextRegionFace(face); + if (face == firstFace) + break; + } + if (seamFactor <= 0.0f) + return 0.0f; + return seamFactor / totalLength; + } + + float computeTextureSeamMetric(Chart *chart, uint32_t firstFace) const + { + float seamLength = 0.0f, totalLength = 0.0f; + uint32_t face = firstFace; + for (;;) { + for (Mesh::FaceEdgeIterator it(m_data.mesh, face); !it.isDone(); it.advance()) { + if (it.isBoundary()) + continue; + if (m_faceCharts[it.oppositeFace()] != chart->id) + continue; + float l = m_data.edgeLengths[it.edge()]; + totalLength += l; + if (!it.isSeam()) + continue; + // Make sure it's a texture seam. + if (it.isTextureSeam()) + seamLength += l; + } + face = m_planarCharts.nextRegionFace(face); + if (face == firstFace) + break; + } + if (seamLength <= 0.0f) + return 0.0f; // Avoid division by zero. + return seamLength / totalLength; + } + + float computeArea(Chart *chart, uint32_t firstFace) const + { + float area = chart->area; + uint32_t face = firstFace; + for (;;) { + area += m_data.faceAreas[face]; + face = m_planarCharts.nextRegionFace(face); + if (face == firstFace) + break; + } + return area; + } + + float computeBoundaryLength(Chart *chart, uint32_t firstFace) const + { + float boundaryLength = chart->boundaryLength; + // Add new edges, subtract edges shared with the chart. + const uint32_t planarRegionId = m_planarCharts.regionIdFromFace(firstFace); + uint32_t face = firstFace; + for (;;) { + for (Mesh::FaceEdgeIterator it(m_data.mesh, face); !it.isDone(); it.advance()) { + const float edgeLength = m_data.edgeLengths[it.edge()]; + if (it.isBoundary()) { + boundaryLength += edgeLength; + } else if (m_planarCharts.regionIdFromFace(it.oppositeFace()) != planarRegionId) { + if (m_faceCharts[it.oppositeFace()] != chart->id) + boundaryLength += edgeLength; + else + boundaryLength -= edgeLength; + } + } + face = m_planarCharts.nextRegionFace(face); + if (face == firstFace) + break; + } + return max(0.0f, boundaryLength); // @@ Hack! + } + + bool mergeChart(Chart *owner, Chart *chart, float sharedBoundaryLength) + { + const uint32_t oldOwnerFaceCount = owner->faces.size(); + const uint32_t chartFaceCount = chart->faces.size(); + owner->faces.push_back(chart->faces); + for (uint32_t i = 0; i < chartFaceCount; i++) { + XA_DEBUG_ASSERT(m_faceCharts[chart->faces[i]] == chart->id); + m_faceCharts[chart->faces[i]] = owner->id; + } + // Compute basis using best fit normal. + Basis basis; + if (!computeChartBasis(owner, &basis)) { + owner->faces.resize(oldOwnerFaceCount); + for (uint32_t i = 0; i < chartFaceCount; i++) + m_faceCharts[chart->faces[i]] = chart->id; + return false; + } + if (dot(basis.normal, m_data.faceNormals[owner->faces[0]]) < 0.0f) // Flip normal if oriented in the wrong direction. + basis.normal = -basis.normal; + // Compute orthogonal parameterization and check that it is valid. + parameterizeChart(owner); + if (!isChartParameterizationValid(owner)) { + owner->faces.resize(oldOwnerFaceCount); + for (uint32_t i = 0; i < chartFaceCount; i++) + m_faceCharts[chart->faces[i]] = chart->id; + return false; + } + // Merge chart. + owner->basis = basis; + owner->failedPlanarRegions.push_back(chart->failedPlanarRegions); + // Update adjacencies? + owner->area += chart->area; + owner->boundaryLength += chart->boundaryLength - sharedBoundaryLength; + // Delete chart. + m_charts[chart->id] = nullptr; + chart->~Chart(); + XA_FREE(chart); + return true; + } + +private: + AtlasData &m_data; + const PlanarCharts &m_planarCharts; + Array m_texcoords; + uint32_t m_facesLeft; + Array m_faceCharts; + Array m_charts; + CostQueue m_bestTriangles; + Array m_tempPoints; + UniformGrid2 m_boundaryGrid; +#if XA_MERGE_CHARTS + // mergeCharts + Array m_sharedBoundaryLengths; + Array m_sharedBoundaryLengthsNoSeams; + Array m_sharedBoundaryEdgeCountNoSeams; +#endif + bool m_placingSeeds; +}; + +struct ChartGeneratorType +{ + enum Enum + { + OriginalUv, + Planar, + Clustered, + Piecewise + }; +}; + +struct Atlas +{ + Atlas() : m_originalUvCharts(m_data), m_planarCharts(m_data), m_clusteredCharts(m_data, m_planarCharts) {} + + uint32_t chartCount() const + { + return m_originalUvCharts.chartCount() + m_planarCharts.chartCount() + m_clusteredCharts.chartCount(); + } + + ConstArrayView chartFaces(uint32_t chartIndex) const + { + if (chartIndex < m_originalUvCharts.chartCount()) + return m_originalUvCharts.chartFaces(chartIndex); + chartIndex -= m_originalUvCharts.chartCount(); + if (chartIndex < m_planarCharts.chartCount()) + return m_planarCharts.chartFaces(chartIndex); + chartIndex -= m_planarCharts.chartCount(); + return m_clusteredCharts.chartFaces(chartIndex); + } + + const Basis &chartBasis(uint32_t chartIndex) const + { + if (chartIndex < m_originalUvCharts.chartCount()) + return m_originalUvCharts.chartBasis(chartIndex); + chartIndex -= m_originalUvCharts.chartCount(); + if (chartIndex < m_planarCharts.chartCount()) + return m_planarCharts.chartBasis(chartIndex); + chartIndex -= m_planarCharts.chartCount(); + return m_clusteredCharts.chartBasis(chartIndex); + } + + ChartGeneratorType::Enum chartGeneratorType(uint32_t chartIndex) const + { + if (chartIndex < m_originalUvCharts.chartCount()) + return ChartGeneratorType::OriginalUv; + chartIndex -= m_originalUvCharts.chartCount(); + if (chartIndex < m_planarCharts.chartCount()) + return ChartGeneratorType::Planar; + return ChartGeneratorType::Clustered; + } + + void reset(const Mesh *mesh, const ChartOptions &options) + { + XA_PROFILE_START(buildAtlasInit) + m_data.options = options; + m_data.mesh = mesh; + m_data.compute(); + XA_PROFILE_END(buildAtlasInit) + } + + void compute() + { + if (m_data.options.useInputMeshUvs) { + XA_PROFILE_START(originalUvCharts) + m_originalUvCharts.compute(); + XA_PROFILE_END(originalUvCharts) + } + XA_PROFILE_START(planarCharts) + m_planarCharts.compute(); + XA_PROFILE_END(planarCharts) + XA_PROFILE_START(clusteredCharts) + m_clusteredCharts.compute(); + XA_PROFILE_END(clusteredCharts) + } + +private: + AtlasData m_data; + OriginalUvCharts m_originalUvCharts; + PlanarCharts m_planarCharts; + ClusteredCharts m_clusteredCharts; +}; + +struct ComputeUvMeshChartsTaskArgs +{ + UvMesh *mesh; + Progress *progress; +}; + +// Charts are found by floodfilling faces without crossing UV seams. +struct ComputeUvMeshChartsTask +{ + ComputeUvMeshChartsTask(ComputeUvMeshChartsTaskArgs *args) : m_mesh(args->mesh), m_progress(args->progress), m_uvToEdgeMap(MemTag::Default, m_mesh->indices.size()), m_faceAssigned(m_mesh->indices.size() / 3) {} + + void run() + { + const uint32_t vertexCount = m_mesh->texcoords.size(); + const uint32_t indexCount = m_mesh->indices.size(); + const uint32_t faceCount = indexCount / 3; + // A vertex can only be assigned to one chart. + m_mesh->vertexToChartMap.resize(vertexCount); + m_mesh->vertexToChartMap.fill(UINT32_MAX); + // Map vertex UV to edge. Face is then edge / 3. + for (uint32_t i = 0; i < indexCount; i++) + m_uvToEdgeMap.add(m_mesh->texcoords[m_mesh->indices[i]]); + // Find charts. + m_faceAssigned.zeroOutMemory(); + for (uint32_t f = 0; f < faceCount; f++) { + if (m_progress->cancel) + return; + m_progress->increment(1); + // Found an unassigned face, see if it can be added. + const uint32_t chartIndex = m_mesh->charts.size(); + if (!canAddFaceToChart(chartIndex, f)) + continue; + // Face is OK, create a new chart with the face. + UvMeshChart *chart = XA_NEW(MemTag::Default, UvMeshChart); + m_mesh->charts.push_back(chart); + chart->material = m_mesh->faceMaterials.isEmpty() ? 0 : m_mesh->faceMaterials[f]; + addFaceToChart(chartIndex, f); + // Walk incident faces and assign them to the chart. + uint32_t f2 = 0; + for (;;) { + bool newFaceAssigned = false; + const uint32_t faceCount2 = chart->faces.size(); + for (; f2 < faceCount2; f2++) { + const uint32_t face = chart->faces[f2]; + for (uint32_t i = 0; i < 3; i++) { + // Add any valid faces with colocal UVs to the chart. + const Vector2 &uv = m_mesh->texcoords[m_mesh->indices[face * 3 + i]]; + uint32_t edge = m_uvToEdgeMap.get(uv); + while (edge != UINT32_MAX) { + const uint32_t newFace = edge / 3; + if (canAddFaceToChart(chartIndex, newFace)) { + addFaceToChart(chartIndex, newFace); + newFaceAssigned = true; + } + edge = m_uvToEdgeMap.getNext(uv, edge); + } + } + } + if (!newFaceAssigned) + break; + } + } + } + +private: + // The chart at chartIndex doesn't have to exist yet. + bool canAddFaceToChart(uint32_t chartIndex, uint32_t face) const + { + if (m_faceAssigned.get(face)) + return false; // Already assigned to a chart. + if (m_mesh->faceIgnore.get(face)) + return false; // Face is ignored (zero area or nan UVs). + if (!m_mesh->faceMaterials.isEmpty() && chartIndex < m_mesh->charts.size()) { + if (m_mesh->faceMaterials[face] != m_mesh->charts[chartIndex]->material) + return false; // Materials don't match. + } + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = m_mesh->indices[face * 3 + i]; + if (m_mesh->vertexToChartMap[vertex] != UINT32_MAX && m_mesh->vertexToChartMap[vertex] != chartIndex) + return false; // Vertex already assigned to another chart. + } + return true; + } + + void addFaceToChart(uint32_t chartIndex, uint32_t face) + { + UvMeshChart *chart = m_mesh->charts[chartIndex]; + m_faceAssigned.set(face); + chart->faces.push_back(face); + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = m_mesh->indices[face * 3 + i]; + m_mesh->vertexToChartMap[vertex] = chartIndex; + chart->indices.push_back(vertex); + } + } + + UvMesh * const m_mesh; + Progress * const m_progress; + HashMap m_uvToEdgeMap; // Face is edge / 3. + BitArray m_faceAssigned; +}; + +static void runComputeUvMeshChartsTask(void * /*groupUserData*/, void *taskUserData) +{ + XA_PROFILE_START(computeChartsThread) + ComputeUvMeshChartsTask task((ComputeUvMeshChartsTaskArgs *)taskUserData); + task.run(); + XA_PROFILE_END(computeChartsThread) +} + +static bool computeUvMeshCharts(TaskScheduler *taskScheduler, ArrayView meshes, ProgressFunc progressFunc, void *progressUserData) +{ + uint32_t totalFaceCount = 0; + for (uint32_t i = 0; i < meshes.length; i++) + totalFaceCount += meshes[i]->indices.size() / 3; + Progress progress(ProgressCategory::ComputeCharts, progressFunc, progressUserData, totalFaceCount); + TaskGroupHandle taskGroup = taskScheduler->createTaskGroup(nullptr, meshes.length); + Array taskArgs; + taskArgs.resize(meshes.length); + for (uint32_t i = 0; i < meshes.length; i++) + { + ComputeUvMeshChartsTaskArgs &args = taskArgs[i]; + args.mesh = meshes[i]; + args.progress = &progress; + Task task; + task.userData = &args; + task.func = runComputeUvMeshChartsTask; + taskScheduler->run(taskGroup, task); + } + taskScheduler->wait(&taskGroup); + return !progress.cancel; +} + +} // namespace segment + +namespace param { + +// Fast sweep in 3 directions +static bool findApproximateDiameterVertices(Mesh *mesh, uint32_t *a, uint32_t *b) +{ + XA_DEBUG_ASSERT(a != nullptr); + XA_DEBUG_ASSERT(b != nullptr); + const uint32_t vertexCount = mesh->vertexCount(); + uint32_t minVertex[3]; + uint32_t maxVertex[3]; + minVertex[0] = minVertex[1] = minVertex[2] = UINT32_MAX; + maxVertex[0] = maxVertex[1] = maxVertex[2] = UINT32_MAX; + for (uint32_t v = 1; v < vertexCount; v++) { + if (mesh->isBoundaryVertex(v)) { + minVertex[0] = minVertex[1] = minVertex[2] = v; + maxVertex[0] = maxVertex[1] = maxVertex[2] = v; + break; + } + } + if (minVertex[0] == UINT32_MAX) { + // Input mesh has not boundaries. + return false; + } + for (uint32_t v = 1; v < vertexCount; v++) { + if (!mesh->isBoundaryVertex(v)) { + // Skip interior vertices. + continue; + } + const Vector3 &pos = mesh->position(v); + if (pos.x < mesh->position(minVertex[0]).x) + minVertex[0] = v; + else if (pos.x > mesh->position(maxVertex[0]).x) + maxVertex[0] = v; + if (pos.y < mesh->position(minVertex[1]).y) + minVertex[1] = v; + else if (pos.y > mesh->position(maxVertex[1]).y) + maxVertex[1] = v; + if (pos.z < mesh->position(minVertex[2]).z) + minVertex[2] = v; + else if (pos.z > mesh->position(maxVertex[2]).z) + maxVertex[2] = v; + } + float lengths[3]; + for (int i = 0; i < 3; i++) { + lengths[i] = length(mesh->position(minVertex[i]) - mesh->position(maxVertex[i])); + } + if (lengths[0] > lengths[1] && lengths[0] > lengths[2]) { + *a = minVertex[0]; + *b = maxVertex[0]; + } else if (lengths[1] > lengths[2]) { + *a = minVertex[1]; + *b = maxVertex[1]; + } else { + *a = minVertex[2]; + *b = maxVertex[2]; + } + return true; +} + +// From OpenNL LSCM example. +// Computes the coordinates of the vertices of a triangle in a local 2D orthonormal basis of the triangle's plane. +static void projectTriangle(Vector3 p0, Vector3 p1, Vector3 p2, Vector2 *z0, Vector2 *z1, Vector2 *z2) +{ + Vector3 X = normalize(p1 - p0); + Vector3 Z = normalize(cross(X, p2 - p0)); + Vector3 Y = cross(Z, X); + Vector3 &O = p0; + *z0 = Vector2(0, 0); + *z1 = Vector2(length(p1 - O), 0); + *z2 = Vector2(dot(p2 - O, X), dot(p2 - O, Y)); +} + +// Conformal relations from Brecht Van Lommel (based on ABF): + +static float vec_angle_cos(const Vector3 &v1, const Vector3 &v2, const Vector3 &v3) +{ + Vector3 d1 = v1 - v2; + Vector3 d2 = v3 - v2; + return clamp(dot(d1, d2) / (length(d1) * length(d2)), -1.0f, 1.0f); +} + +static float vec_angle(const Vector3 &v1, const Vector3 &v2, const Vector3 &v3) +{ + float dot = vec_angle_cos(v1, v2, v3); + return acosf(dot); +} + +static void triangle_angles(const Vector3 &v1, const Vector3 &v2, const Vector3 &v3, float *a1, float *a2, float *a3) +{ + *a1 = vec_angle(v3, v1, v2); + *a2 = vec_angle(v1, v2, v3); + *a3 = kPi - *a2 - *a1; +} + +static bool setup_abf_relations(opennl::NLContext *context, int id0, int id1, int id2, const Vector3 &p0, const Vector3 &p1, const Vector3 &p2) +{ + // @@ IC: Wouldn't it be more accurate to return cos and compute 1-cos^2? + // It does indeed seem to be a little bit more robust. + // @@ Need to revisit this more carefully! + float a0, a1, a2; + triangle_angles(p0, p1, p2, &a0, &a1, &a2); + if (a0 == 0.0f || a1 == 0.0f || a2 == 0.0f) + return false; + float s0 = sinf(a0); + float s1 = sinf(a1); + float s2 = sinf(a2); + if (s1 > s0 && s1 > s2) { + swap(s1, s2); + swap(s0, s1); + swap(a1, a2); + swap(a0, a1); + swap(id1, id2); + swap(id0, id1); + } else if (s0 > s1 && s0 > s2) { + swap(s0, s2); + swap(s0, s1); + swap(a0, a2); + swap(a0, a1); + swap(id0, id2); + swap(id0, id1); + } + float c0 = cosf(a0); + float ratio = (s2 == 0.0f) ? 1.0f : s1 / s2; + float cosine = c0 * ratio; + float sine = s0 * ratio; + // Note : 2*id + 0 --> u + // 2*id + 1 --> v + int u0_id = 2 * id0 + 0; + int v0_id = 2 * id0 + 1; + int u1_id = 2 * id1 + 0; + int v1_id = 2 * id1 + 1; + int u2_id = 2 * id2 + 0; + int v2_id = 2 * id2 + 1; + // Real part + opennl::nlBegin(context, NL_ROW); + opennl::nlCoefficient(context, u0_id, cosine - 1.0f); + opennl::nlCoefficient(context, v0_id, -sine); + opennl::nlCoefficient(context, u1_id, -cosine); + opennl::nlCoefficient(context, v1_id, sine); + opennl::nlCoefficient(context, u2_id, 1); + opennl::nlEnd(context, NL_ROW); + // Imaginary part + opennl::nlBegin(context, NL_ROW); + opennl::nlCoefficient(context, u0_id, sine); + opennl::nlCoefficient(context, v0_id, cosine - 1.0f); + opennl::nlCoefficient(context, u1_id, -sine); + opennl::nlCoefficient(context, v1_id, -cosine); + opennl::nlCoefficient(context, v2_id, 1); + opennl::nlEnd(context, NL_ROW); + return true; +} + +static bool computeLeastSquaresConformalMap(Mesh *mesh) +{ + uint32_t lockedVertex0, lockedVertex1; + if (!findApproximateDiameterVertices(mesh, &lockedVertex0, &lockedVertex1)) { + // Mesh has no boundaries. + return false; + } + const uint32_t vertexCount = mesh->vertexCount(); + opennl::NLContext *context = opennl::nlNewContext(); + opennl::nlSolverParameteri(context, NL_NB_VARIABLES, int(2 * vertexCount)); + opennl::nlSolverParameteri(context, NL_MAX_ITERATIONS, int(5 * vertexCount)); + opennl::nlBegin(context, NL_SYSTEM); + ArrayView texcoords = mesh->texcoords(); + for (uint32_t i = 0; i < vertexCount; i++) { + opennl::nlSetVariable(context, 2 * i, texcoords[i].x); + opennl::nlSetVariable(context, 2 * i + 1, texcoords[i].y); + if (i == lockedVertex0 || i == lockedVertex1) { + opennl::nlLockVariable(context, 2 * i); + opennl::nlLockVariable(context, 2 * i + 1); + } + } + opennl::nlBegin(context, NL_MATRIX); + const uint32_t faceCount = mesh->faceCount(); + ConstArrayView positions = mesh->positions(); + ConstArrayView indices = mesh->indices(); + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t v0 = indices[f * 3 + 0]; + const uint32_t v1 = indices[f * 3 + 1]; + const uint32_t v2 = indices[f * 3 + 2]; + if (!setup_abf_relations(context, v0, v1, v2, positions[v0], positions[v1], positions[v2])) { + Vector2 z0, z1, z2; + projectTriangle(positions[v0], positions[v1], positions[v2], &z0, &z1, &z2); + double a = z1.x - z0.x; + double b = z1.y - z0.y; + double c = z2.x - z0.x; + double d = z2.y - z0.y; + XA_DEBUG_ASSERT(b == 0.0); + // Note : 2*id + 0 --> u + // 2*id + 1 --> v + uint32_t u0_id = 2 * v0; + uint32_t v0_id = 2 * v0 + 1; + uint32_t u1_id = 2 * v1; + uint32_t v1_id = 2 * v1 + 1; + uint32_t u2_id = 2 * v2; + uint32_t v2_id = 2 * v2 + 1; + // Note : b = 0 + // Real part + opennl::nlBegin(context, NL_ROW); + opennl::nlCoefficient(context, u0_id, -a+c) ; + opennl::nlCoefficient(context, v0_id, b-d) ; + opennl::nlCoefficient(context, u1_id, -c) ; + opennl::nlCoefficient(context, v1_id, d) ; + opennl::nlCoefficient(context, u2_id, a); + opennl::nlEnd(context, NL_ROW); + // Imaginary part + opennl::nlBegin(context, NL_ROW); + opennl::nlCoefficient(context, u0_id, -b+d); + opennl::nlCoefficient(context, v0_id, -a+c); + opennl::nlCoefficient(context, u1_id, -d); + opennl::nlCoefficient(context, v1_id, -c); + opennl::nlCoefficient(context, v2_id, a); + opennl::nlEnd(context, NL_ROW); + } + } + opennl::nlEnd(context, NL_MATRIX); + opennl::nlEnd(context, NL_SYSTEM); + if (!opennl::nlSolve(context)) { + opennl::nlDeleteContext(context); + return false; + } + for (uint32_t i = 0; i < vertexCount; i++) { + const double u = opennl::nlGetVariable(context, 2 * i); + const double v = opennl::nlGetVariable(context, 2 * i + 1); + texcoords[i] = Vector2((float)u, (float)v); + XA_DEBUG_ASSERT(!isNan(mesh->texcoord(i).x)); + XA_DEBUG_ASSERT(!isNan(mesh->texcoord(i).y)); + } + opennl::nlDeleteContext(context); + return true; +} + +struct PiecewiseParam +{ + void reset(const Mesh *mesh) + { + m_mesh = mesh; + const uint32_t faceCount = m_mesh->faceCount(); + const uint32_t vertexCount = m_mesh->vertexCount(); + m_texcoords.resize(vertexCount); + m_patch.reserve(faceCount); + m_candidates.reserve(faceCount); + m_faceInAnyPatch.resize(faceCount); + m_faceInAnyPatch.zeroOutMemory(); + m_faceInvalid.resize(faceCount); + m_faceInPatch.resize(faceCount); + m_vertexInPatch.resize(vertexCount); + m_faceToCandidate.resize(faceCount); + } + + ConstArrayView chartFaces() const { return m_patch; } + ConstArrayView texcoords() const { return m_texcoords; } + + bool computeChart() + { + // Clear per-patch state. + m_patch.clear(); + m_candidates.clear(); + m_faceToCandidate.zeroOutMemory(); + m_faceInvalid.zeroOutMemory(); + m_faceInPatch.zeroOutMemory(); + m_vertexInPatch.zeroOutMemory(); + // Add the seed face (first unassigned face) to the patch. + const uint32_t faceCount = m_mesh->faceCount(); + uint32_t seed = UINT32_MAX; + for (uint32_t f = 0; f < faceCount; f++) { + if (m_faceInAnyPatch.get(f)) + continue; + seed = f; + // Add all 3 vertices. + Vector2 texcoords[3]; + orthoProjectFace(seed, texcoords); + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = m_mesh->vertexAt(seed * 3 + i); + m_vertexInPatch.set(vertex); + m_texcoords[vertex] = texcoords[i]; + } + addFaceToPatch(seed); + // Initialize the boundary grid. + m_boundaryGrid.reset(m_texcoords, m_mesh->indices()); + for (Mesh::FaceEdgeIterator it(m_mesh, seed); !it.isDone(); it.advance()) + m_boundaryGrid.append(it.edge()); + break; + } + if (seed == UINT32_MAX) + return false; + for (;;) { + // Find the candidate with the lowest cost. + float lowestCost = FLT_MAX; + Candidate *bestCandidate = nullptr; + for (uint32_t i = 0; i < m_candidates.size(); i++) { + Candidate *candidate = m_candidates[i]; + if (candidate->maxCost < lowestCost) { + lowestCost = candidate->maxCost; + bestCandidate = candidate; + } + } + if (!bestCandidate) + break; + XA_DEBUG_ASSERT(!bestCandidate->prev); // Must be head of linked candidates. + // Compute the position by averaging linked candidates (candidates that share the same free vertex). + Vector2 position(0.0f); + uint32_t n = 0; + for (CandidateIterator it(bestCandidate); !it.isDone(); it.advance()) { + position += it.current()->position; + n++; + } + position *= 1.0f / (float)n; + const uint32_t freeVertex = bestCandidate->vertex; + XA_DEBUG_ASSERT(!isNan(position.x)); + XA_DEBUG_ASSERT(!isNan(position.y)); + m_texcoords[freeVertex] = position; + // Check for flipped faces. This is also done when candidates are first added, but the averaged position of the free vertex is different now, so check again. + bool invalid = false; + for (CandidateIterator it(bestCandidate); !it.isDone(); it.advance()) { + const uint32_t vertex0 = m_mesh->vertexAt(meshEdgeIndex0(it.current()->patchEdge)); + const uint32_t vertex1 = m_mesh->vertexAt(meshEdgeIndex1(it.current()->patchEdge)); + const float freeVertexOrient = orientToEdge(m_texcoords[vertex0], m_texcoords[vertex1], position); + if ((it.current()->patchVertexOrient < 0.0f && freeVertexOrient < 0.0f) || (it.current()->patchVertexOrient > 0.0f && freeVertexOrient > 0.0f)) { + invalid = true; + break; + } + } + // Check for zero area and flipped faces (using area). + for (CandidateIterator it(bestCandidate); !it.isDone(); it.advance()) { + const Vector2 a = m_texcoords[m_mesh->vertexAt(it.current()->face * 3 + 0)]; + const Vector2 b = m_texcoords[m_mesh->vertexAt(it.current()->face * 3 + 1)]; + const Vector2 c = m_texcoords[m_mesh->vertexAt(it.current()->face * 3 + 2)]; + const float area = triangleArea(a, b, c); + if (area <= 0.0f) { + invalid = true; + break; + } + } + // Check for boundary intersection. + if (!invalid) { + XA_PROFILE_START(parameterizeChartsPiecewiseBoundaryIntersection) + // Test candidate edges that would form part of the new patch boundary. + // Ignore boundary edges that would become internal if the candidate faces were added to the patch. + m_newBoundaryEdges.clear(); + m_ignoreBoundaryEdges.clear(); + for (CandidateIterator candidateIt(bestCandidate); !candidateIt.isDone(); candidateIt.advance()) { + for (Mesh::FaceEdgeIterator it(m_mesh, candidateIt.current()->face); !it.isDone(); it.advance()) { + const uint32_t oface = it.oppositeFace(); + if (oface == UINT32_MAX || !m_faceInPatch.get(oface)) + m_newBoundaryEdges.push_back(it.edge()); + if (oface != UINT32_MAX && m_faceInPatch.get(oface)) + m_ignoreBoundaryEdges.push_back(it.oppositeEdge()); + } + } + invalid = m_boundaryGrid.intersect(m_mesh->epsilon(), m_newBoundaryEdges, m_ignoreBoundaryEdges); + XA_PROFILE_END(parameterizeChartsPiecewiseBoundaryIntersection) + } + if (invalid) { + // Mark all faces of linked candidates as invalid. + for (CandidateIterator it(bestCandidate); !it.isDone(); it.advance()) + m_faceInvalid.set(it.current()->face); + removeLinkedCandidates(bestCandidate); + } else { + // Add vertex to the patch. + m_vertexInPatch.set(freeVertex); + // Add faces to the patch. + for (CandidateIterator it(bestCandidate); !it.isDone(); it.advance()) + addFaceToPatch(it.current()->face); + // Successfully added candidate face(s) to patch. + removeLinkedCandidates(bestCandidate); + // Reset the grid with all edges on the patch boundary. + XA_PROFILE_START(parameterizeChartsPiecewiseBoundaryIntersection) + m_boundaryGrid.reset(m_texcoords, m_mesh->indices()); + for (uint32_t i = 0; i < m_patch.size(); i++) { + for (Mesh::FaceEdgeIterator it(m_mesh, m_patch[i]); !it.isDone(); it.advance()) { + const uint32_t oface = it.oppositeFace(); + if (oface == UINT32_MAX || !m_faceInPatch.get(oface)) + m_boundaryGrid.append(it.edge()); + } + } + XA_PROFILE_END(parameterizeChartsPiecewiseBoundaryIntersection) + } + } + return true; + } + +private: + struct Candidate + { + uint32_t face, vertex; + Candidate *prev, *next; // The previous/next candidate with the same vertex. + Vector2 position; + float cost; + float maxCost; // Of all linked candidates. + uint32_t patchEdge; + float patchVertexOrient; + }; + + struct CandidateIterator + { + CandidateIterator(Candidate *head) : m_current(head) { XA_DEBUG_ASSERT(!head->prev); } + void advance() { if (m_current != nullptr) { m_current = m_current->next; } } + bool isDone() const { return !m_current; } + Candidate *current() { return m_current; } + + private: + Candidate *m_current; + }; + + const Mesh *m_mesh; + Array m_texcoords; + BitArray m_faceInAnyPatch; // Face is in a previous chart patch or the current patch. + Array m_candidates; // Incident faces to the patch. + Array m_faceToCandidate; + Array m_patch; // The current chart patch. + BitArray m_faceInPatch, m_vertexInPatch; // Face/vertex is in the current patch. + BitArray m_faceInvalid; // Face cannot be added to the patch - flipped, cost too high or causes boundary intersection. + UniformGrid2 m_boundaryGrid; + Array m_newBoundaryEdges, m_ignoreBoundaryEdges; // Temp arrays used when testing for boundary intersection. + + void addFaceToPatch(uint32_t face) + { + XA_DEBUG_ASSERT(!m_faceInPatch.get(face)); + XA_DEBUG_ASSERT(!m_faceInAnyPatch.get(face)); + m_patch.push_back(face); + m_faceInPatch.set(face); + m_faceInAnyPatch.set(face); + // Find new candidate faces on the patch incident to the newly added face. + for (Mesh::FaceEdgeIterator it(m_mesh, face); !it.isDone(); it.advance()) { + const uint32_t oface = it.oppositeFace(); + if (oface == UINT32_MAX || m_faceInAnyPatch.get(oface) || m_faceToCandidate[oface]) + continue; + // Found an active edge on the patch front. + // Find the free vertex (the vertex that isn't on the active edge). + // Compute the orientation of the other patch face vertex to the active edge. + uint32_t freeVertex = UINT32_MAX; + float orient = 0.0f; + for (uint32_t j = 0; j < 3; j++) { + const uint32_t vertex = m_mesh->vertexAt(oface * 3 + j); + if (vertex != it.vertex0() && vertex != it.vertex1()) { + freeVertex = vertex; + orient = orientToEdge(m_texcoords[it.vertex0()], m_texcoords[it.vertex1()], m_texcoords[m_mesh->vertexAt(face * 3 + j)]); + break; + } + } + XA_DEBUG_ASSERT(freeVertex != UINT32_MAX); + if (m_vertexInPatch.get(freeVertex)) { +#if 0 + // If the free vertex is already in the patch, the face is enclosed by the patch. Add the face to the patch - don't need to assign texcoords. + freeVertex = UINT32_MAX; + addFaceToPatch(oface); +#endif + continue; + } + // Check this here rather than above so faces enclosed by the patch are always added. + if (m_faceInvalid.get(oface)) + continue; + addCandidateFace(it.edge(), orient, oface, it.oppositeEdge(), freeVertex); + } + } + + void addCandidateFace(uint32_t patchEdge, float patchVertexOrient, uint32_t face, uint32_t edge, uint32_t freeVertex) + { + XA_DEBUG_ASSERT(!m_faceToCandidate[face]); + Vector2 texcoords[3]; + orthoProjectFace(face, texcoords); + // Find corresponding vertices between the patch edge and candidate edge. + const uint32_t vertex0 = m_mesh->vertexAt(meshEdgeIndex0(patchEdge)); + const uint32_t vertex1 = m_mesh->vertexAt(meshEdgeIndex1(patchEdge)); + uint32_t localVertex0 = UINT32_MAX, localVertex1 = UINT32_MAX, localFreeVertex = UINT32_MAX; + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = m_mesh->vertexAt(face * 3 + i); + if (vertex == m_mesh->vertexAt(meshEdgeIndex1(edge))) + localVertex0 = i; + else if (vertex == m_mesh->vertexAt(meshEdgeIndex0(edge))) + localVertex1 = i; + else + localFreeVertex = i; + } + // Scale orthogonal projection to match the patch edge. + const Vector2 patchEdgeVec = m_texcoords[vertex1] - m_texcoords[vertex0]; + const Vector2 localEdgeVec = texcoords[localVertex1] - texcoords[localVertex0]; + const float len1 = length(patchEdgeVec); + const float len2 = length(localEdgeVec); + if (len1 <= 0.0f || len2 <= 0.0f) + return; // Zero length edge. + const float scale = len1 / len2; + for (uint32_t i = 0; i < 3; i++) + texcoords[i] *= scale; + // Translate to the first vertex on the patch edge. + const Vector2 translate = m_texcoords[vertex0] - texcoords[localVertex0]; + for (uint32_t i = 0; i < 3; i++) + texcoords[i] += translate; + // Compute the angle between the patch edge and the corresponding local edge. + const float angle = atan2f(patchEdgeVec.y, patchEdgeVec.x) - atan2f(localEdgeVec.y, localEdgeVec.x); + // Rotate so the patch edge and the corresponding local edge occupy the same space. + for (uint32_t i = 0; i < 3; i++) { + if (i == localVertex0) + continue; + Vector2 &uv = texcoords[i]; + uv -= texcoords[localVertex0]; // Rotate around the first vertex. + const float c = cosf(angle); + const float s = sinf(angle); + const float x = uv.x * c - uv.y * s; + const float y = uv.y * c + uv.x * s; + uv.x = x + texcoords[localVertex0].x; + uv.y = y + texcoords[localVertex0].y; + } + if (isNan(texcoords[localFreeVertex].x) || isNan(texcoords[localFreeVertex].y)) { + m_faceInvalid.set(face); + return; + } + // Check for local overlap (flipped triangle). + // The patch face vertex that isn't on the active edge and the free vertex should be oriented on opposite sides to the active edge. + const float freeVertexOrient = orientToEdge(m_texcoords[vertex0], m_texcoords[vertex1], texcoords[localFreeVertex]); + if ((patchVertexOrient < 0.0f && freeVertexOrient < 0.0f) || (patchVertexOrient > 0.0f && freeVertexOrient > 0.0f)) { + m_faceInvalid.set(face); + return; + } + const float stretch = computeStretch(m_mesh->position(vertex0), m_mesh->position(vertex1), m_mesh->position(freeVertex), texcoords[0], texcoords[1], texcoords[2]); + if (stretch >= FLT_MAX) { + m_faceInvalid.set(face); + return; + } + const float cost = fabsf(stretch - 1.0f); + if (cost > 0.5f) { + m_faceInvalid.set(face); + return; + } + // Add the candidate. + Candidate *candidate = XA_ALLOC(MemTag::Default, Candidate); + candidate->face = face; + candidate->vertex = freeVertex; + candidate->position = texcoords[localFreeVertex]; + candidate->prev = candidate->next = nullptr; + candidate->cost = candidate->maxCost = cost; + candidate->patchEdge = patchEdge; + candidate->patchVertexOrient = patchVertexOrient; + m_candidates.push_back(candidate); + m_faceToCandidate[face] = candidate; + // Link with candidates that share the same vertex. Append to tail. + for (uint32_t i = 0; i < m_candidates.size() - 1; i++) { + if (m_candidates[i]->vertex == candidate->vertex) { + Candidate *tail = m_candidates[i]; + for (;;) { + if (tail->next) + tail = tail->next; + else + break; + } + candidate->prev = tail; + candidate->next = nullptr; + tail->next = candidate; + break; + } + } + // Set max cost for linked candidates. + Candidate *head = linkedCandidateHead(candidate); + float maxCost = 0.0f; + for (CandidateIterator it(head); !it.isDone(); it.advance()) + maxCost = max(maxCost, it.current()->cost); + for (CandidateIterator it(head); !it.isDone(); it.advance()) + it.current()->maxCost = maxCost; + } + + Candidate *linkedCandidateHead(Candidate *candidate) + { + Candidate *current = candidate; + for (;;) { + if (!current->prev) + break; + current = current->prev; + } + return current; + } + + void removeLinkedCandidates(Candidate *head) + { + XA_DEBUG_ASSERT(!head->prev); + Candidate *current = head; + while (current) { + Candidate *next = current->next; + m_faceToCandidate[current->face] = nullptr; + for (uint32_t i = 0; i < m_candidates.size(); i++) { + if (m_candidates[i] == current) { + m_candidates.removeAt(i); + break; + } + } + XA_FREE(current); + current = next; + } + } + + void orthoProjectFace(uint32_t face, Vector2 *texcoords) const + { + const Vector3 normal = -m_mesh->computeFaceNormal(face); + const Vector3 tangent = normalize(m_mesh->position(m_mesh->vertexAt(face * 3 + 1)) - m_mesh->position(m_mesh->vertexAt(face * 3 + 0))); + const Vector3 bitangent = cross(normal, tangent); + for (uint32_t i = 0; i < 3; i++) { + const Vector3 &pos = m_mesh->position(m_mesh->vertexAt(face * 3 + i)); + texcoords[i] = Vector2(dot(tangent, pos), dot(bitangent, pos)); + } + } + + float parametricArea(const Vector2 *texcoords) const + { + const Vector2 &v1 = texcoords[0]; + const Vector2 &v2 = texcoords[1]; + const Vector2 &v3 = texcoords[2]; + return ((v2.x - v1.x) * (v3.y - v1.y) - (v3.x - v1.x) * (v2.y - v1.y)) * 0.5f; + } + + float computeStretch(Vector3 p1, Vector3 p2, Vector3 p3, Vector2 t1, Vector2 t2, Vector2 t3) const + { + float parametricArea = ((t2.y - t1.y) * (t3.x - t1.x) - (t3.y - t1.y) * (t2.x - t1.x)) * 0.5f; + if (isZero(parametricArea, kAreaEpsilon)) + return FLT_MAX; + if (parametricArea < 0.0f) + parametricArea = fabsf(parametricArea); + const float geometricArea = length(cross(p2 - p1, p3 - p1)) * 0.5f; + if (parametricArea <= geometricArea) + return parametricArea / geometricArea; + else + return geometricArea / parametricArea; + } + + // Return value is positive if the point is one side of the edge, negative if on the other side. + float orientToEdge(Vector2 edgeVertex0, Vector2 edgeVertex1, Vector2 point) const + { + return (edgeVertex0.x - point.x) * (edgeVertex1.y - point.y) - (edgeVertex0.y - point.y) * (edgeVertex1.x - point.x); + } +}; + +// Estimate quality of existing parameterization. +struct Quality +{ + // computeBoundaryIntersection + bool boundaryIntersection = false; + + // computeFlippedFaces + uint32_t totalTriangleCount = 0; + uint32_t flippedTriangleCount = 0; + uint32_t zeroAreaTriangleCount = 0; + + // computeMetrics + float totalParametricArea = 0.0f; + float totalGeometricArea = 0.0f; + float stretchMetric = 0.0f; + float maxStretchMetric = 0.0f; + float conformalMetric = 0.0f; + float authalicMetric = 0.0f; + + void computeBoundaryIntersection(const Mesh *mesh, UniformGrid2 &boundaryGrid) + { + const Array &boundaryEdges = mesh->boundaryEdges(); + const uint32_t boundaryEdgeCount = boundaryEdges.size(); + boundaryGrid.reset(mesh->texcoords(), mesh->indices(), boundaryEdgeCount); + for (uint32_t i = 0; i < boundaryEdgeCount; i++) + boundaryGrid.append(boundaryEdges[i]); + boundaryIntersection = boundaryGrid.intersect(mesh->epsilon()); +#if XA_DEBUG_EXPORT_BOUNDARY_GRID + static int exportIndex = 0; + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_boundary_grid_%03d.tga", exportIndex); + boundaryGrid.debugExport(filename); + exportIndex++; +#endif + } + + void computeFlippedFaces(const Mesh *mesh, Array *flippedFaces) + { + totalTriangleCount = flippedTriangleCount = zeroAreaTriangleCount = 0; + if (flippedFaces) + flippedFaces->clear(); + const uint32_t faceCount = mesh->faceCount(); + for (uint32_t f = 0; f < faceCount; f++) { + Vector2 texcoord[3]; + for (int i = 0; i < 3; i++) { + const uint32_t v = mesh->vertexAt(f * 3 + i); + texcoord[i] = mesh->texcoord(v); + } + totalTriangleCount++; + const float t1 = texcoord[0].x; + const float s1 = texcoord[0].y; + const float t2 = texcoord[1].x; + const float s2 = texcoord[1].y; + const float t3 = texcoord[2].x; + const float s3 = texcoord[2].y; + const float parametricArea = ((s2 - s1) * (t3 - t1) - (s3 - s1) * (t2 - t1)) * 0.5f; + if (isZero(parametricArea, kAreaEpsilon)) { + zeroAreaTriangleCount++; + continue; + } + if (parametricArea < 0.0f) { + // Count flipped triangles. + flippedTriangleCount++; + if (flippedFaces) + flippedFaces->push_back(f); + } + } + if (flippedTriangleCount + zeroAreaTriangleCount == totalTriangleCount) { + // If all triangles are flipped, then none are. + if (flippedFaces) + flippedFaces->clear(); + flippedTriangleCount = 0; + } + if (flippedTriangleCount > totalTriangleCount / 2) + { + // If more than half the triangles are flipped, reverse the flipped / not flipped classification. + flippedTriangleCount = totalTriangleCount - flippedTriangleCount; + if (flippedFaces) { + Array temp; + flippedFaces->copyTo(temp); + flippedFaces->clear(); + for (uint32_t f = 0; f < faceCount; f++) { + bool match = false; + for (uint32_t ff = 0; ff < temp.size(); ff++) { + if (temp[ff] == f) { + match = true; + break; + } + } + if (!match) + flippedFaces->push_back(f); + } + } + } + } + + void computeMetrics(const Mesh *mesh) + { + totalGeometricArea = totalParametricArea = 0.0f; + stretchMetric = maxStretchMetric = conformalMetric = authalicMetric = 0.0f; + const uint32_t faceCount = mesh->faceCount(); + for (uint32_t f = 0; f < faceCount; f++) { + Vector3 pos[3]; + Vector2 texcoord[3]; + for (int i = 0; i < 3; i++) { + const uint32_t v = mesh->vertexAt(f * 3 + i); + pos[i] = mesh->position(v); + texcoord[i] = mesh->texcoord(v); + } + // Evaluate texture stretch metric. See: + // - "Texture Mapping Progressive Meshes", Sander, Snyder, Gortler & Hoppe + // - "Mesh Parameterization: Theory and Practice", Siggraph'07 Course Notes, Hormann, Levy & Sheffer. + const float t1 = texcoord[0].x; + const float s1 = texcoord[0].y; + const float t2 = texcoord[1].x; + const float s2 = texcoord[1].y; + const float t3 = texcoord[2].x; + const float s3 = texcoord[2].y; + float parametricArea = ((s2 - s1) * (t3 - t1) - (s3 - s1) * (t2 - t1)) * 0.5f; + if (isZero(parametricArea, kAreaEpsilon)) + continue; + if (parametricArea < 0.0f) + parametricArea = fabsf(parametricArea); + const float geometricArea = length(cross(pos[1] - pos[0], pos[2] - pos[0])) / 2; + const Vector3 Ss = (pos[0] * (t2 - t3) + pos[1] * (t3 - t1) + pos[2] * (t1 - t2)) / (2 * parametricArea); + const Vector3 St = (pos[0] * (s3 - s2) + pos[1] * (s1 - s3) + pos[2] * (s2 - s1)) / (2 * parametricArea); + const float a = dot(Ss, Ss); // E + const float b = dot(Ss, St); // F + const float c = dot(St, St); // G + // Compute eigen-values of the first fundamental form: + const float sigma1 = sqrtf(0.5f * max(0.0f, a + c - sqrtf(square(a - c) + 4 * square(b)))); // gamma uppercase, min eigenvalue. + const float sigma2 = sqrtf(0.5f * max(0.0f, a + c + sqrtf(square(a - c) + 4 * square(b)))); // gamma lowercase, max eigenvalue. + XA_ASSERT(sigma2 > sigma1 || equal(sigma1, sigma2, kEpsilon)); + // isometric: sigma1 = sigma2 = 1 + // conformal: sigma1 / sigma2 = 1 + // authalic: sigma1 * sigma2 = 1 + const float rmsStretch = sqrtf((a + c) * 0.5f); + const float rmsStretch2 = sqrtf((square(sigma1) + square(sigma2)) * 0.5f); + XA_DEBUG_ASSERT(equal(rmsStretch, rmsStretch2, 0.01f)); + XA_UNUSED(rmsStretch2); + stretchMetric += square(rmsStretch) * geometricArea; + maxStretchMetric = max(maxStretchMetric, sigma2); + if (!isZero(sigma1, 0.000001f)) { + // sigma1 is zero when geometricArea is zero. + conformalMetric += (sigma2 / sigma1) * geometricArea; + } + authalicMetric += (sigma1 * sigma2) * geometricArea; + // Accumulate total areas. + totalGeometricArea += geometricArea; + totalParametricArea += parametricArea; + } + XA_DEBUG_ASSERT(isFinite(totalParametricArea) && totalParametricArea >= 0); + XA_DEBUG_ASSERT(isFinite(totalGeometricArea) && totalGeometricArea >= 0); + XA_DEBUG_ASSERT(isFinite(stretchMetric)); + XA_DEBUG_ASSERT(isFinite(maxStretchMetric)); + XA_DEBUG_ASSERT(isFinite(conformalMetric)); + XA_DEBUG_ASSERT(isFinite(authalicMetric)); + if (totalGeometricArea > 0.0f) { + const float normFactor = sqrtf(totalParametricArea / totalGeometricArea); + stretchMetric = sqrtf(stretchMetric / totalGeometricArea) * normFactor; + maxStretchMetric *= normFactor; + conformalMetric = sqrtf(conformalMetric / totalGeometricArea); + authalicMetric = sqrtf(authalicMetric / totalGeometricArea); + } + } +}; + +struct ChartCtorBuffers +{ + Array chartMeshIndices; + Array unifiedMeshIndices; +}; + +class Chart +{ +public: + Chart(const Basis &basis, segment::ChartGeneratorType::Enum generatorType, ConstArrayView faces, const Mesh *sourceMesh, uint32_t chartGroupId, uint32_t chartId) : m_basis(basis), m_unifiedMesh(nullptr), m_type(ChartType::LSCM), m_generatorType(generatorType), m_tjunctionCount(0), m_originalVertexCount(0), m_isInvalid(false) + { + XA_UNUSED(chartGroupId); + XA_UNUSED(chartId); + m_faceToSourceFaceMap.copyFrom(faces.data, faces.length); + const uint32_t approxVertexCount = min(faces.length * 3, sourceMesh->vertexCount()); + m_unifiedMesh = XA_NEW_ARGS(MemTag::Mesh, Mesh, sourceMesh->epsilon(), approxVertexCount, faces.length); + HashMap> sourceVertexToUnifiedVertexMap(MemTag::Mesh, approxVertexCount), sourceVertexToChartVertexMap(MemTag::Mesh, approxVertexCount); + m_originalIndices.resize(faces.length * 3); + // Add geometry. + const uint32_t faceCount = faces.length; + for (uint32_t f = 0; f < faceCount; f++) { + uint32_t unifiedIndices[3]; + for (uint32_t i = 0; i < 3; i++) { + const uint32_t sourceVertex = sourceMesh->vertexAt(m_faceToSourceFaceMap[f] * 3 + i); + uint32_t sourceUnifiedVertex = sourceMesh->firstColocalVertex(sourceVertex); + if (m_generatorType == segment::ChartGeneratorType::OriginalUv && sourceVertex != sourceUnifiedVertex) { + // Original UVs: don't unify vertices with different UVs; we want to preserve UVs. + if (!equal(sourceMesh->texcoord(sourceVertex), sourceMesh->texcoord(sourceUnifiedVertex), sourceMesh->epsilon())) + sourceUnifiedVertex = sourceVertex; + } + uint32_t unifiedVertex = sourceVertexToUnifiedVertexMap.get(sourceUnifiedVertex); + if (unifiedVertex == UINT32_MAX) { + unifiedVertex = sourceVertexToUnifiedVertexMap.add(sourceUnifiedVertex); + m_unifiedMesh->addVertex(sourceMesh->position(sourceVertex), Vector3(0.0f), sourceMesh->texcoord(sourceVertex)); + } + if (sourceVertexToChartVertexMap.get(sourceVertex) == UINT32_MAX) { + sourceVertexToChartVertexMap.add(sourceVertex); + m_vertexToSourceVertexMap.push_back(sourceVertex); + m_chartVertexToUnifiedVertexMap.push_back(unifiedVertex); + m_originalVertexCount++; + } + m_originalIndices[f * 3 + i] = sourceVertexToChartVertexMap.get(sourceVertex);; + XA_DEBUG_ASSERT(m_originalIndices[f * 3 + i] != UINT32_MAX); + unifiedIndices[i] = sourceVertexToUnifiedVertexMap.get(sourceUnifiedVertex); + XA_DEBUG_ASSERT(unifiedIndices[i] != UINT32_MAX); + } + m_unifiedMesh->addFace(unifiedIndices); + } + m_unifiedMesh->createBoundaries(); + if (m_generatorType == segment::ChartGeneratorType::Planar) { + m_type = ChartType::Planar; + return; + } +#if XA_CHECK_T_JUNCTIONS + m_tjunctionCount = meshCheckTJunctions(*m_unifiedMesh); +#if XA_DEBUG_EXPORT_OBJ_TJUNCTION + if (m_tjunctionCount > 0) { + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_mesh_%03u_chartgroup_%03u_chart_%03u_tjunction.obj", sourceMesh->id(), chartGroupId, chartId); + m_unifiedMesh->writeObjFile(filename); + } +#endif +#endif + } + + Chart(ChartCtorBuffers &buffers, const Chart *parent, const Mesh *parentMesh, ConstArrayView faces, ConstArrayView texcoords, const Mesh *sourceMesh) : m_unifiedMesh(nullptr), m_type(ChartType::Piecewise), m_generatorType(segment::ChartGeneratorType::Piecewise), m_tjunctionCount(0), m_originalVertexCount(0), m_isInvalid(false) + { + const uint32_t faceCount = faces.length; + m_faceToSourceFaceMap.resize(faceCount); + for (uint32_t i = 0; i < faceCount; i++) + m_faceToSourceFaceMap[i] = parent->m_faceToSourceFaceMap[faces[i]]; // Map faces to parent chart source mesh. + // Copy face indices. + Array &chartMeshIndices = buffers.chartMeshIndices; + chartMeshIndices.resize(sourceMesh->vertexCount()); + chartMeshIndices.fillBytes(0xff); + m_unifiedMesh = XA_NEW_ARGS(MemTag::Mesh, Mesh, sourceMesh->epsilon(), m_faceToSourceFaceMap.size() * 3, m_faceToSourceFaceMap.size()); + HashMap> sourceVertexToUnifiedVertexMap(MemTag::Mesh, m_faceToSourceFaceMap.size() * 3); + // Add vertices. + for (uint32_t f = 0; f < faceCount; f++) { + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = sourceMesh->vertexAt(m_faceToSourceFaceMap[f] * 3 + i); + const uint32_t sourceUnifiedVertex = sourceMesh->firstColocalVertex(vertex); + const uint32_t parentVertex = parentMesh->vertexAt(faces[f] * 3 + i); + uint32_t unifiedVertex = sourceVertexToUnifiedVertexMap.get(sourceUnifiedVertex); + if (unifiedVertex == UINT32_MAX) { + unifiedVertex = sourceVertexToUnifiedVertexMap.add(sourceUnifiedVertex); + m_unifiedMesh->addVertex(sourceMesh->position(vertex), Vector3(0.0f), texcoords[parentVertex]); + } + if (chartMeshIndices[vertex] == UINT32_MAX) { + chartMeshIndices[vertex] = m_originalVertexCount; + m_originalVertexCount++; + m_vertexToSourceVertexMap.push_back(vertex); + m_chartVertexToUnifiedVertexMap.push_back(unifiedVertex); + } + } + } + // Add faces. + m_originalIndices.resize(faceCount * 3); + for (uint32_t f = 0; f < faceCount; f++) { + uint32_t unifiedIndices[3]; + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = sourceMesh->vertexAt(m_faceToSourceFaceMap[f] * 3 + i); + m_originalIndices[f * 3 + i] = chartMeshIndices[vertex]; + const uint32_t unifiedVertex = sourceMesh->firstColocalVertex(vertex); + unifiedIndices[i] = sourceVertexToUnifiedVertexMap.get(unifiedVertex); + } + m_unifiedMesh->addFace(unifiedIndices); + } + m_unifiedMesh->createBoundaries(); + // Need to store texcoords for backup/restore so packing can be run multiple times. + backupTexcoords(); + } + + ~Chart() + { + if (m_unifiedMesh) { + m_unifiedMesh->~Mesh(); + XA_FREE(m_unifiedMesh); + m_unifiedMesh = nullptr; + } + } + + bool isInvalid() const { return m_isInvalid; } + ChartType type() const { return m_type; } + segment::ChartGeneratorType::Enum generatorType() const { return m_generatorType; } + uint32_t tjunctionCount() const { return m_tjunctionCount; } + const Quality &quality() const { return m_quality; } +#if XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION + const Array ¶mFlippedFaces() const { return m_paramFlippedFaces; } +#endif + uint32_t mapFaceToSourceFace(uint32_t i) const { return m_faceToSourceFaceMap[i]; } + uint32_t mapChartVertexToSourceVertex(uint32_t i) const { return m_vertexToSourceVertexMap[i]; } + const Mesh *unifiedMesh() const { return m_unifiedMesh; } + Mesh *unifiedMesh() { return m_unifiedMesh; } + + // Vertex count of the chart mesh before unifying vertices. + uint32_t originalVertexCount() const { return m_originalVertexCount; } + + uint32_t originalVertexToUnifiedVertex(uint32_t v) const { return m_chartVertexToUnifiedVertexMap[v]; } + + ConstArrayView originalVertices() const { return m_originalIndices; } + + void parameterize(const ChartOptions &options, UniformGrid2 &boundaryGrid) + { + const uint32_t unifiedVertexCount = m_unifiedMesh->vertexCount(); + if (m_generatorType == segment::ChartGeneratorType::OriginalUv) { + } else { + // Project vertices to plane. + XA_PROFILE_START(parameterizeChartsOrthogonal) + for (uint32_t i = 0; i < unifiedVertexCount; i++) + m_unifiedMesh->texcoord(i) = Vector2(dot(m_basis.tangent, m_unifiedMesh->position(i)), dot(m_basis.bitangent, m_unifiedMesh->position(i))); + XA_PROFILE_END(parameterizeChartsOrthogonal) + // Computing charts checks for flipped triangles and boundary intersection. Don't need to do that again here if chart is planar. + if (m_type != ChartType::Planar && m_generatorType != segment::ChartGeneratorType::OriginalUv) { + XA_PROFILE_START(parameterizeChartsEvaluateQuality) + m_quality.computeBoundaryIntersection(m_unifiedMesh, boundaryGrid); + m_quality.computeFlippedFaces(m_unifiedMesh, nullptr); + m_quality.computeMetrics(m_unifiedMesh); + XA_PROFILE_END(parameterizeChartsEvaluateQuality) + // Use orthogonal parameterization if quality is acceptable. + if (!m_quality.boundaryIntersection && m_quality.flippedTriangleCount == 0 && m_quality.zeroAreaTriangleCount == 0 && m_quality.totalGeometricArea > 0.0f && m_quality.stretchMetric <= 1.1f && m_quality.maxStretchMetric <= 1.25f) + m_type = ChartType::Ortho; + } + if (m_type == ChartType::LSCM) { + XA_PROFILE_START(parameterizeChartsLSCM) + if (options.paramFunc) { + options.paramFunc(&m_unifiedMesh->position(0).x, &m_unifiedMesh->texcoord(0).x, m_unifiedMesh->vertexCount(), m_unifiedMesh->indices().data, m_unifiedMesh->indexCount()); + } + else + computeLeastSquaresConformalMap(m_unifiedMesh); + XA_PROFILE_END(parameterizeChartsLSCM) + XA_PROFILE_START(parameterizeChartsEvaluateQuality) + m_quality.computeBoundaryIntersection(m_unifiedMesh, boundaryGrid); +#if XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION + m_quality.computeFlippedFaces(m_unifiedMesh, &m_paramFlippedFaces); +#else + m_quality.computeFlippedFaces(m_unifiedMesh, nullptr); +#endif + // Don't need to call computeMetrics here, that's only used in evaluateOrthoQuality to determine if quality is acceptable enough to use ortho projection. + if (m_quality.boundaryIntersection || m_quality.flippedTriangleCount > 0 || m_quality.zeroAreaTriangleCount > 0) + m_isInvalid = true; + XA_PROFILE_END(parameterizeChartsEvaluateQuality) + } + } + if (options.fixWinding && m_unifiedMesh->computeFaceParametricArea(0) < 0.0f) { + for (uint32_t i = 0; i < unifiedVertexCount; i++) + m_unifiedMesh->texcoord(i).x *= -1.0f; + } +#if XA_CHECK_PARAM_WINDING + const uint32_t faceCount = m_unifiedMesh->faceCount(); + uint32_t flippedCount = 0; + for (uint32_t i = 0; i < faceCount; i++) { + const float area = m_unifiedMesh->computeFaceParametricArea(i); + if (area < 0.0f) + flippedCount++; + } + if (flippedCount == faceCount) { + XA_PRINT_WARNING("param: all faces flipped\n"); + } else if (flippedCount > 0) { + XA_PRINT_WARNING("param: %u / %u faces flipped\n", flippedCount, faceCount); + } +#endif + +#if XA_DEBUG_ALL_CHARTS_INVALID + m_isInvalid = true; +#endif + // Need to store texcoords for backup/restore so packing can be run multiple times. + backupTexcoords(); + } + + Vector2 computeParametricBounds() const + { + Vector2 minCorner(FLT_MAX, FLT_MAX); + Vector2 maxCorner(-FLT_MAX, -FLT_MAX); + const uint32_t vertexCount = m_unifiedMesh->vertexCount(); + for (uint32_t v = 0; v < vertexCount; v++) { + minCorner = min(minCorner, m_unifiedMesh->texcoord(v)); + maxCorner = max(maxCorner, m_unifiedMesh->texcoord(v)); + } + return (maxCorner - minCorner) * 0.5f; + } + +#if XA_CHECK_PIECEWISE_CHART_QUALITY + void evaluateQuality(UniformGrid2 &boundaryGrid) + { + m_quality.computeBoundaryIntersection(m_unifiedMesh, boundaryGrid); +#if XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION + m_quality.computeFlippedFaces(m_unifiedMesh, &m_paramFlippedFaces); +#else + m_quality.computeFlippedFaces(m_unifiedMesh, nullptr); +#endif + if (m_quality.boundaryIntersection || m_quality.flippedTriangleCount > 0 || m_quality.zeroAreaTriangleCount > 0) + m_isInvalid = true; + } +#endif + + void restoreTexcoords() + { + memcpy(m_unifiedMesh->texcoords().data, m_backupTexcoords.data(), m_unifiedMesh->vertexCount() * sizeof(Vector2)); + } + +private: + void backupTexcoords() + { + m_backupTexcoords.resize(m_unifiedMesh->vertexCount()); + memcpy(m_backupTexcoords.data(), m_unifiedMesh->texcoords().data, m_unifiedMesh->vertexCount() * sizeof(Vector2)); + } + + Basis m_basis; + Mesh *m_unifiedMesh; + ChartType m_type; + segment::ChartGeneratorType::Enum m_generatorType; + uint32_t m_tjunctionCount; + + uint32_t m_originalVertexCount; + Array m_originalIndices; + + // List of faces of the source mesh that belong to this chart. + Array m_faceToSourceFaceMap; + + // Map vertices of the chart mesh to vertices of the source mesh. + Array m_vertexToSourceVertexMap; + + Array m_chartVertexToUnifiedVertexMap; + + Array m_backupTexcoords; + + Quality m_quality; +#if XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION + Array m_paramFlippedFaces; +#endif + bool m_isInvalid; +}; + +struct CreateAndParameterizeChartTaskGroupArgs +{ + Progress *progress; + ThreadLocal *boundaryGrid; + ThreadLocal *chartBuffers; + const ChartOptions *options; + ThreadLocal *pp; +}; + +struct CreateAndParameterizeChartTaskArgs +{ + const Basis *basis; + Chart *chart; // output + Array charts; // output (if more than one chart) + segment::ChartGeneratorType::Enum chartGeneratorType; + const Mesh *mesh; + ConstArrayView faces; + uint32_t chartGroupId; + uint32_t chartId; +}; + +static void runCreateAndParameterizeChartTask(void *groupUserData, void *taskUserData) +{ + XA_PROFILE_START(createChartMeshAndParameterizeThread) + auto groupArgs = (CreateAndParameterizeChartTaskGroupArgs *)groupUserData; + auto args = (CreateAndParameterizeChartTaskArgs *)taskUserData; + XA_PROFILE_START(createChartMesh) + args->chart = XA_NEW_ARGS(MemTag::Default, Chart, *args->basis, args->chartGeneratorType, args->faces, args->mesh, args->chartGroupId, args->chartId); + XA_PROFILE_END(createChartMesh) + XA_PROFILE_START(parameterizeCharts) + args->chart->parameterize(*groupArgs->options, groupArgs->boundaryGrid->get()); + XA_PROFILE_END(parameterizeCharts) +#if XA_RECOMPUTE_CHARTS + if (!args->chart->isInvalid()) { + XA_PROFILE_END(createChartMeshAndParameterizeThread) + return; + } + // Recompute charts with invalid parameterizations. + XA_PROFILE_START(parameterizeChartsRecompute) + Chart *invalidChart = args->chart; + const Mesh *invalidMesh = invalidChart->unifiedMesh(); + PiecewiseParam &pp = groupArgs->pp->get(); + pp.reset(invalidMesh); +#if XA_DEBUG_EXPORT_OBJ_RECOMPUTED_CHARTS + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_mesh_%03u_chartgroup_%03u_chart_%03u_recomputed.obj", args->mesh->id(), args->chartGroupId, args->chartId); + FILE *file; + XA_FOPEN(file, filename, "w"); + uint32_t subChartIndex = 0; +#endif + for (;;) { + XA_PROFILE_START(parameterizeChartsPiecewise) + const bool facesRemaining = pp.computeChart(); + XA_PROFILE_END(parameterizeChartsPiecewise) + if (!facesRemaining) + break; + Chart *chart = XA_NEW_ARGS(MemTag::Default, Chart, groupArgs->chartBuffers->get(), invalidChart, invalidMesh, pp.chartFaces(), pp.texcoords(), args->mesh); +#if XA_CHECK_PIECEWISE_CHART_QUALITY + chart->evaluateQuality(args->boundaryGrid->get()); +#endif + args->charts.push_back(chart); +#if XA_DEBUG_EXPORT_OBJ_RECOMPUTED_CHARTS + if (file) { + for (uint32_t j = 0; j < invalidMesh->vertexCount(); j++) { + fprintf(file, "v %g %g %g\n", invalidMesh->position(j).x, invalidMesh->position(j).y, invalidMesh->position(j).z); + fprintf(file, "vt %g %g\n", pp.texcoords()[j].x, pp.texcoords()[j].y); + } + fprintf(file, "o chart%03u\n", subChartIndex); + fprintf(file, "s off\n"); + for (uint32_t f = 0; f < pp.chartFaces().length; f++) { + fprintf(file, "f "); + const uint32_t face = pp.chartFaces()[f]; + for (uint32_t j = 0; j < 3; j++) { + const uint32_t index = invalidMesh->vertexCount() * subChartIndex + invalidMesh->vertexAt(face * 3 + j) + 1; // 1-indexed + fprintf(file, "%d/%d/%c", index, index, j == 2 ? '\n' : ' '); + } + } + } + subChartIndex++; +#endif + } +#if XA_DEBUG_EXPORT_OBJ_RECOMPUTED_CHARTS + if (file) + fclose(file); +#endif + XA_PROFILE_END(parameterizeChartsRecompute) +#endif // XA_RECOMPUTE_CHARTS + XA_PROFILE_END(createChartMeshAndParameterizeThread) + // Update progress. + groupArgs->progress->increment(args->faces.length); +} + +// Set of charts corresponding to mesh faces in the same face group. +class ChartGroup +{ +public: + ChartGroup(uint32_t id, const Mesh *sourceMesh, const MeshFaceGroups *sourceMeshFaceGroups, MeshFaceGroups::Handle faceGroup) : m_id(id), m_sourceMesh(sourceMesh), m_sourceMeshFaceGroups(sourceMeshFaceGroups), m_faceGroup(faceGroup) + { + } + + ~ChartGroup() + { + for (uint32_t i = 0; i < m_charts.size(); i++) { + m_charts[i]->~Chart(); + XA_FREE(m_charts[i]); + } + } + + uint32_t chartCount() const { return m_charts.size(); } + Chart *chartAt(uint32_t i) const { return m_charts[i]; } + uint32_t faceCount() const { return m_sourceMeshFaceGroups->faceCount(m_faceGroup); } + + void computeCharts(TaskScheduler *taskScheduler, const ChartOptions &options, Progress *progress, segment::Atlas &atlas, ThreadLocal *boundaryGrid, ThreadLocal *chartBuffers, ThreadLocal *piecewiseParam) + { + // This function may be called multiple times, so destroy existing charts. + for (uint32_t i = 0; i < m_charts.size(); i++) { + m_charts[i]->~Chart(); + XA_FREE(m_charts[i]); + } + // Create mesh from source mesh, using only the faces in this face group. + XA_PROFILE_START(createChartGroupMesh) + Mesh *mesh = createMesh(); + XA_PROFILE_END(createChartGroupMesh) + // Segment mesh into charts (arrays of faces). +#if XA_DEBUG_SINGLE_CHART + XA_UNUSED(options); + XA_UNUSED(atlas); + const uint32_t chartCount = 1; + uint32_t offset; + Basis chartBasis; + Fit::computeBasis(&mesh->position(0), mesh->vertexCount(), &chartBasis); + Array chartFaces; + chartFaces.resize(1 + mesh->faceCount()); + chartFaces[0] = mesh->faceCount(); + for (uint32_t i = 0; i < chartFaces.size() - 1; i++) + chartFaces[i + 1] = m_faceToSourceFaceMap[i]; + // Destroy mesh. + const uint32_t faceCount = mesh->faceCount(); + mesh->~Mesh(); + XA_FREE(mesh); +#else + XA_PROFILE_START(buildAtlas) + atlas.reset(mesh, options); + atlas.compute(); + XA_PROFILE_END(buildAtlas) + // Update progress. + progress->increment(faceCount()); +#if XA_DEBUG_EXPORT_OBJ_CHARTS + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_mesh_%03u_chartgroup_%03u_charts.obj", m_sourceMesh->id(), m_id); + FILE *file; + XA_FOPEN(file, filename, "w"); + if (file) { + mesh->writeObjVertices(file); + for (uint32_t i = 0; i < atlas.chartCount(); i++) { + fprintf(file, "o chart_%04d\n", i); + fprintf(file, "s off\n"); + ConstArrayView faces = atlas.chartFaces(i); + for (uint32_t f = 0; f < faces.length; f++) + mesh->writeObjFace(file, faces[f]); + } + mesh->writeObjBoundaryEges(file); + fclose(file); + } +#endif + // Destroy mesh. + const uint32_t faceCount = mesh->faceCount(); + mesh->~Mesh(); + XA_FREE(mesh); + XA_PROFILE_START(copyChartFaces) + if (progress->cancel) + return; + // Copy faces from segment::Atlas to m_chartFaces array with etc. encoding. + // segment::Atlas faces refer to the chart group mesh. Map them to the input mesh instead. + const uint32_t chartCount = atlas.chartCount(); + Array chartFaces; + chartFaces.resize(chartCount + faceCount); + uint32_t offset = 0; + for (uint32_t i = 0; i < chartCount; i++) { + ConstArrayView faces = atlas.chartFaces(i); + chartFaces[offset++] = faces.length; + for (uint32_t j = 0; j < faces.length; j++) + chartFaces[offset++] = m_faceToSourceFaceMap[faces[j]]; + } + XA_PROFILE_END(copyChartFaces) +#endif + XA_PROFILE_START(createChartMeshAndParameterizeReal) + CreateAndParameterizeChartTaskGroupArgs groupArgs; + groupArgs.progress = progress; + groupArgs.boundaryGrid = boundaryGrid; + groupArgs.chartBuffers = chartBuffers; + groupArgs.options = &options; + groupArgs.pp = piecewiseParam; + TaskGroupHandle taskGroup = taskScheduler->createTaskGroup(&groupArgs, chartCount); + Array taskArgs; + taskArgs.resize(chartCount); + taskArgs.runCtors(); // Has Array member. + offset = 0; + for (uint32_t i = 0; i < chartCount; i++) { + CreateAndParameterizeChartTaskArgs &args = taskArgs[i]; +#if XA_DEBUG_SINGLE_CHART + args.basis = &chartBasis; + args.isPlanar = false; +#else + args.basis = &atlas.chartBasis(i); + args.chartGeneratorType = atlas.chartGeneratorType(i); +#endif + args.chart = nullptr; + args.chartGroupId = m_id; + args.chartId = i; + const uint32_t chartFaceCount = chartFaces[offset++]; + args.faces = ConstArrayView(&chartFaces[offset], chartFaceCount); + offset += chartFaceCount; + args.mesh = m_sourceMesh; + Task task; + task.userData = &args; + task.func = runCreateAndParameterizeChartTask; + taskScheduler->run(taskGroup, task); + } + taskScheduler->wait(&taskGroup); + XA_PROFILE_END(createChartMeshAndParameterizeReal) +#if XA_RECOMPUTE_CHARTS + // Count charts. Skip invalid ones and include new ones added by recomputing. + uint32_t newChartCount = 0; + for (uint32_t i = 0; i < chartCount; i++) { + if (taskArgs[i].chart->isInvalid()) + newChartCount += taskArgs[i].charts.size(); + else + newChartCount++; + } + m_charts.resize(newChartCount); + // Add valid charts first. Destroy invalid ones. + uint32_t current = 0; + for (uint32_t i = 0; i < chartCount; i++) { + Chart *chart = taskArgs[i].chart; + if (chart->isInvalid()) { + chart->~Chart(); + XA_FREE(chart); + continue; + } + m_charts[current++] = chart; + } + // Now add new charts. + for (uint32_t i = 0; i < chartCount; i++) { + CreateAndParameterizeChartTaskArgs &args = taskArgs[i]; + for (uint32_t j = 0; j < args.charts.size(); j++) + m_charts[current++] = args.charts[j]; + } +#else // XA_RECOMPUTE_CHARTS + m_charts.resize(chartCount); + for (uint32_t i = 0; i < chartCount; i++) + m_charts[i] = taskArgs[i].chart; +#endif // XA_RECOMPUTE_CHARTS + taskArgs.runDtors(); // Has Array member. + } + +private: + Mesh *createMesh() + { + XA_DEBUG_ASSERT(m_faceGroup != MeshFaceGroups::kInvalid); + // Create new mesh from the source mesh, using faces that belong to this group. + m_faceToSourceFaceMap.reserve(m_sourceMeshFaceGroups->faceCount(m_faceGroup)); + for (MeshFaceGroups::Iterator it(m_sourceMeshFaceGroups, m_faceGroup); !it.isDone(); it.advance()) + m_faceToSourceFaceMap.push_back(it.face()); + // Only initial meshes has ignored faces. The only flag we care about is HasNormals. + const uint32_t faceCount = m_faceToSourceFaceMap.size(); + XA_DEBUG_ASSERT(faceCount > 0); + const uint32_t approxVertexCount = min(faceCount * 3, m_sourceMesh->vertexCount()); + Mesh *mesh = XA_NEW_ARGS(MemTag::Mesh, Mesh, m_sourceMesh->epsilon(), approxVertexCount, faceCount, m_sourceMesh->flags() & MeshFlags::HasNormals); + HashMap> sourceVertexToVertexMap(MemTag::Mesh, approxVertexCount); + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t face = m_faceToSourceFaceMap[f]; + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = m_sourceMesh->vertexAt(face * 3 + i); + if (sourceVertexToVertexMap.get(vertex) == UINT32_MAX) { + sourceVertexToVertexMap.add(vertex); + Vector3 normal(0.0f); + if (m_sourceMesh->flags() & MeshFlags::HasNormals) + normal = m_sourceMesh->normal(vertex); + mesh->addVertex(m_sourceMesh->position(vertex), normal, m_sourceMesh->texcoord(vertex)); + } + } + } + // Add faces. + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t face = m_faceToSourceFaceMap[f]; + XA_DEBUG_ASSERT(!m_sourceMesh->isFaceIgnored(face)); + uint32_t indices[3]; + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = m_sourceMesh->vertexAt(face * 3 + i); + indices[i] = sourceVertexToVertexMap.get(vertex); + XA_DEBUG_ASSERT(indices[i] != UINT32_MAX); + } + // Don't copy flags - ignored faces aren't used by chart groups, they are handled by InvalidMeshGeometry. + mesh->addFace(indices); + } + XA_PROFILE_START(createChartGroupMeshColocals) + mesh->createColocals(); + XA_PROFILE_END(createChartGroupMeshColocals) + XA_PROFILE_START(createChartGroupMeshBoundaries) + mesh->createBoundaries(); + mesh->destroyEdgeMap(); // Only needed it for createBoundaries. + XA_PROFILE_END(createChartGroupMeshBoundaries) +#if XA_DEBUG_EXPORT_OBJ_CHART_GROUPS + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_mesh_%03u_chartgroup_%03u.obj", m_sourceMesh->id(), m_id); + mesh->writeObjFile(filename); +#endif + return mesh; + } + + const uint32_t m_id; + const Mesh * const m_sourceMesh; + const MeshFaceGroups * const m_sourceMeshFaceGroups; + const MeshFaceGroups::Handle m_faceGroup; + Array m_faceToSourceFaceMap; // List of faces of the source mesh that belong to this chart group. + Array m_charts; +}; + +struct ChartGroupComputeChartsTaskGroupArgs +{ + ThreadLocal *atlas; + const ChartOptions *options; + Progress *progress; + TaskScheduler *taskScheduler; + ThreadLocal *boundaryGrid; + ThreadLocal *chartBuffers; + ThreadLocal *piecewiseParam; +}; + +static void runChartGroupComputeChartsTask(void *groupUserData, void *taskUserData) +{ + auto args = (ChartGroupComputeChartsTaskGroupArgs *)groupUserData; + auto chartGroup = (ChartGroup *)taskUserData; + if (args->progress->cancel) + return; + XA_PROFILE_START(chartGroupComputeChartsThread) + chartGroup->computeCharts(args->taskScheduler, *args->options, args->progress, args->atlas->get(), args->boundaryGrid, args->chartBuffers, args->piecewiseParam); + XA_PROFILE_END(chartGroupComputeChartsThread) +} + +struct MeshComputeChartsTaskGroupArgs +{ + ThreadLocal *atlas; + const ChartOptions *options; + Progress *progress; + TaskScheduler *taskScheduler; + ThreadLocal *boundaryGrid; + ThreadLocal *chartBuffers; + ThreadLocal *piecewiseParam; +}; + +struct MeshComputeChartsTaskArgs +{ + const Mesh *sourceMesh; + Array *chartGroups; // output + InvalidMeshGeometry *invalidMeshGeometry; // output +}; + +#if XA_DEBUG_EXPORT_OBJ_FACE_GROUPS +static uint32_t s_faceGroupsCurrentVertex = 0; +#endif + +static void runMeshComputeChartsTask(void *groupUserData, void *taskUserData) +{ + auto groupArgs = (MeshComputeChartsTaskGroupArgs *)groupUserData; + auto args = (MeshComputeChartsTaskArgs *)taskUserData; + if (groupArgs->progress->cancel) + return; + XA_PROFILE_START(computeChartsThread) + // Create face groups. + XA_PROFILE_START(createFaceGroups) + MeshFaceGroups *meshFaceGroups = XA_NEW_ARGS(MemTag::Mesh, MeshFaceGroups, args->sourceMesh); + meshFaceGroups->compute(); + const uint32_t chartGroupCount = meshFaceGroups->groupCount(); + XA_PROFILE_END(createFaceGroups) + if (groupArgs->progress->cancel) + goto cleanup; +#if XA_DEBUG_EXPORT_OBJ_FACE_GROUPS + { + static std::mutex s_mutex; + std::lock_guard lock(s_mutex); + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_face_groups.obj"); + FILE *file; + XA_FOPEN(file, filename, s_faceGroupsCurrentVertex == 0 ? "w" : "a"); + if (file) { + const Mesh *mesh = args->sourceMesh; + mesh->writeObjVertices(file); + // groups + uint32_t numGroups = 0; + for (uint32_t i = 0; i < mesh->faceCount(); i++) { + if (meshFaceGroups->groupAt(i) != MeshFaceGroups::kInvalid) + numGroups = max(numGroups, meshFaceGroups->groupAt(i) + 1); + } + for (uint32_t i = 0; i < numGroups; i++) { + fprintf(file, "o mesh_%03u_group_%04d\n", mesh->id(), i); + fprintf(file, "s off\n"); + for (uint32_t f = 0; f < mesh->faceCount(); f++) { + if (meshFaceGroups->groupAt(f) == i) + mesh->writeObjFace(file, f, s_faceGroupsCurrentVertex); + } + } + fprintf(file, "o mesh_%03u_group_ignored\n", mesh->id()); + fprintf(file, "s off\n"); + for (uint32_t f = 0; f < mesh->faceCount(); f++) { + if (meshFaceGroups->groupAt(f) == MeshFaceGroups::kInvalid) + mesh->writeObjFace(file, f, s_faceGroupsCurrentVertex); + } + mesh->writeObjBoundaryEges(file); + s_faceGroupsCurrentVertex += mesh->vertexCount(); + fclose(file); + } + } +#endif + // Create a chart group for each face group. + args->chartGroups->resize(chartGroupCount); + for (uint32_t i = 0; i < chartGroupCount; i++) + (*args->chartGroups)[i] = XA_NEW_ARGS(MemTag::Default, ChartGroup, i, args->sourceMesh, meshFaceGroups, MeshFaceGroups::Handle(i)); + // Extract invalid geometry via the invalid face group (MeshFaceGroups::kInvalid). + { + XA_PROFILE_START(extractInvalidMeshGeometry) + args->invalidMeshGeometry->extract(args->sourceMesh, meshFaceGroups); + XA_PROFILE_END(extractInvalidMeshGeometry) + } + // One task for each chart group - compute charts. + { + XA_PROFILE_START(chartGroupComputeChartsReal) + // Sort chart groups by face count. + Array chartGroupSortData; + chartGroupSortData.resize(chartGroupCount); + for (uint32_t i = 0; i < chartGroupCount; i++) + chartGroupSortData[i] = (float)(*args->chartGroups)[i]->faceCount(); + RadixSort chartGroupSort; + chartGroupSort.sort(chartGroupSortData); + // Larger chart groups are added first to reduce the chance of thread starvation. + ChartGroupComputeChartsTaskGroupArgs taskGroupArgs; + taskGroupArgs.atlas = groupArgs->atlas; + taskGroupArgs.options = groupArgs->options; + taskGroupArgs.progress = groupArgs->progress; + taskGroupArgs.taskScheduler = groupArgs->taskScheduler; + taskGroupArgs.boundaryGrid = groupArgs->boundaryGrid; + taskGroupArgs.chartBuffers = groupArgs->chartBuffers; + taskGroupArgs.piecewiseParam = groupArgs->piecewiseParam; + TaskGroupHandle taskGroup = groupArgs->taskScheduler->createTaskGroup(&taskGroupArgs, chartGroupCount); + for (uint32_t i = 0; i < chartGroupCount; i++) { + Task task; + task.userData = (*args->chartGroups)[chartGroupCount - i - 1]; + task.func = runChartGroupComputeChartsTask; + groupArgs->taskScheduler->run(taskGroup, task); + } + groupArgs->taskScheduler->wait(&taskGroup); + XA_PROFILE_END(chartGroupComputeChartsReal) + } + XA_PROFILE_END(computeChartsThread) +cleanup: + if (meshFaceGroups) { + meshFaceGroups->~MeshFaceGroups(); + XA_FREE(meshFaceGroups); + } +} + +/// An atlas is a set of chart groups. +class Atlas +{ +public: + Atlas() : m_chartsComputed(false) {} + + ~Atlas() + { + for (uint32_t i = 0; i < m_meshChartGroups.size(); i++) { + for (uint32_t j = 0; j < m_meshChartGroups[i].size(); j++) { + m_meshChartGroups[i][j]->~ChartGroup(); + XA_FREE(m_meshChartGroups[i][j]); + } + } + m_meshChartGroups.runDtors(); + m_invalidMeshGeometry.runDtors(); + } + + uint32_t meshCount() const { return m_meshes.size(); } + const InvalidMeshGeometry &invalidMeshGeometry(uint32_t meshIndex) const { return m_invalidMeshGeometry[meshIndex]; } + bool chartsComputed() const { return m_chartsComputed; } + uint32_t chartGroupCount(uint32_t mesh) const { return m_meshChartGroups[mesh].size(); } + const ChartGroup *chartGroupAt(uint32_t mesh, uint32_t group) const { return m_meshChartGroups[mesh][group]; } + + void addMesh(const Mesh *mesh) + { + m_meshes.push_back(mesh); + } + + bool computeCharts(TaskScheduler *taskScheduler, const ChartOptions &options, ProgressFunc progressFunc, void *progressUserData) + { + XA_PROFILE_START(computeChartsReal) +#if XA_DEBUG_EXPORT_OBJ_PLANAR_REGIONS + segment::s_planarRegionsCurrentRegion = segment::s_planarRegionsCurrentVertex = 0; +#endif + // Progress is per-face x 2 (1 for chart faces, 1 for parameterized chart faces). + const uint32_t meshCount = m_meshes.size(); + uint32_t totalFaceCount = 0; + for (uint32_t i = 0; i < meshCount; i++) + totalFaceCount += m_meshes[i]->faceCount(); + Progress progress(ProgressCategory::ComputeCharts, progressFunc, progressUserData, totalFaceCount * 2); + m_chartsComputed = false; + // Clear chart groups, since this function may be called multiple times. + if (!m_meshChartGroups.isEmpty()) { + for (uint32_t i = 0; i < m_meshChartGroups.size(); i++) { + for (uint32_t j = 0; j < m_meshChartGroups[i].size(); j++) { + m_meshChartGroups[i][j]->~ChartGroup(); + XA_FREE(m_meshChartGroups[i][j]); + } + m_meshChartGroups[i].clear(); + } + XA_ASSERT(m_meshChartGroups.size() == meshCount); // The number of meshes shouldn't have changed. + } + m_meshChartGroups.resize(meshCount); + m_meshChartGroups.runCtors(); + m_invalidMeshGeometry.resize(meshCount); + m_invalidMeshGeometry.runCtors(); + // One task per mesh. + Array taskArgs; + taskArgs.resize(meshCount); + for (uint32_t i = 0; i < meshCount; i++) { + MeshComputeChartsTaskArgs &args = taskArgs[i]; + args.sourceMesh = m_meshes[i]; + args.chartGroups = &m_meshChartGroups[i]; + args.invalidMeshGeometry = &m_invalidMeshGeometry[i]; + } + // Sort meshes by indexCount. + Array meshSortData; + meshSortData.resize(meshCount); + for (uint32_t i = 0; i < meshCount; i++) + meshSortData[i] = (float)m_meshes[i]->indexCount(); + RadixSort meshSort; + meshSort.sort(meshSortData); + // Larger meshes are added first to reduce the chance of thread starvation. + ThreadLocal atlas; + ThreadLocal boundaryGrid; // For Quality boundary intersection. + ThreadLocal chartBuffers; + ThreadLocal piecewiseParam; + MeshComputeChartsTaskGroupArgs taskGroupArgs; + taskGroupArgs.atlas = &atlas; + taskGroupArgs.options = &options; + taskGroupArgs.progress = &progress; + taskGroupArgs.taskScheduler = taskScheduler; + taskGroupArgs.boundaryGrid = &boundaryGrid; + taskGroupArgs.chartBuffers = &chartBuffers; + taskGroupArgs.piecewiseParam = &piecewiseParam; + TaskGroupHandle taskGroup = taskScheduler->createTaskGroup(&taskGroupArgs, meshCount); + for (uint32_t i = 0; i < meshCount; i++) { + Task task; + task.userData = &taskArgs[meshSort.ranks()[meshCount - i - 1]]; + task.func = runMeshComputeChartsTask; + taskScheduler->run(taskGroup, task); + } + taskScheduler->wait(&taskGroup); + XA_PROFILE_END(computeChartsReal) + if (progress.cancel) + return false; + m_chartsComputed = true; + return true; + } + +private: + Array m_meshes; + Array m_invalidMeshGeometry; // 1 per mesh. + Array > m_meshChartGroups; + bool m_chartsComputed; +}; + +} // namespace param + +namespace pack { + +class AtlasImage +{ +public: + AtlasImage(uint32_t width, uint32_t height) : m_width(width), m_height(height) + { + m_data.resize(m_width * m_height); + memset(m_data.data(), 0, sizeof(uint32_t) * m_data.size()); + } + + void resize(uint32_t width, uint32_t height) + { + Array data; + data.resize(width * height); + memset(data.data(), 0, sizeof(uint32_t) * data.size()); + for (uint32_t y = 0; y < min(m_height, height); y++) + memcpy(&data[y * width], &m_data[y * m_width], min(m_width, width) * sizeof(uint32_t)); + m_width = width; + m_height = height; + data.moveTo(m_data); + } + + void addChart(uint32_t chartIndex, const BitImage *image, const BitImage *imageBilinear, const BitImage *imagePadding, int atlas_w, int atlas_h, int offset_x, int offset_y) + { + const int w = image->width(); + const int h = image->height(); + for (int y = 0; y < h; y++) { + const int yy = y + offset_y; + if (yy < 0) + continue; + for (int x = 0; x < w; x++) { + const int xx = x + offset_x; + if (xx >= 0 && xx < atlas_w && yy < atlas_h) { + const uint32_t dataOffset = xx + yy * m_width; + if (image->get(x, y)) { + XA_DEBUG_ASSERT(m_data[dataOffset] == 0); + m_data[dataOffset] = chartIndex | kImageHasChartIndexBit; + } else if (imageBilinear && imageBilinear->get(x, y)) { + XA_DEBUG_ASSERT(m_data[dataOffset] == 0); + m_data[dataOffset] = chartIndex | kImageHasChartIndexBit | kImageIsBilinearBit; + } else if (imagePadding && imagePadding->get(x, y)) { + XA_DEBUG_ASSERT(m_data[dataOffset] == 0); + m_data[dataOffset] = chartIndex | kImageHasChartIndexBit | kImageIsPaddingBit; + } + } + } + } + } + + void copyTo(uint32_t *dest, uint32_t destWidth, uint32_t destHeight, int padding) const + { + for (uint32_t y = 0; y < destHeight; y++) + memcpy(&dest[y * destWidth], &m_data[padding + (y + padding) * m_width], destWidth * sizeof(uint32_t)); + } + +#if XA_DEBUG_EXPORT_ATLAS_IMAGES + void writeTga(const char *filename, uint32_t width, uint32_t height) const + { + Array image; + image.resize(width * height * 3); + for (uint32_t y = 0; y < height; y++) { + if (y >= m_height) + continue; + for (uint32_t x = 0; x < width; x++) { + if (x >= m_width) + continue; + const uint32_t data = m_data[x + y * m_width]; + uint8_t *bgr = &image[(x + y * width) * 3]; + if (data == 0) { + bgr[0] = bgr[1] = bgr[2] = 0; + continue; + } + const uint32_t chartIndex = data & kImageChartIndexMask; + if (data & kImageIsPaddingBit) { + bgr[0] = 0; + bgr[1] = 0; + bgr[2] = 255; + } else if (data & kImageIsBilinearBit) { + bgr[0] = 0; + bgr[1] = 255; + bgr[2] = 0; + } else { + const int mix = 192; + srand((unsigned int)chartIndex); + bgr[0] = uint8_t((rand() % 255 + mix) * 0.5f); + bgr[1] = uint8_t((rand() % 255 + mix) * 0.5f); + bgr[2] = uint8_t((rand() % 255 + mix) * 0.5f); + } + } + } + WriteTga(filename, image.data(), width, height); + } +#endif + +private: + uint32_t m_width, m_height; + Array m_data; +}; + +struct Chart +{ + int32_t atlasIndex; + uint32_t material; + ConstArrayView indices; + float parametricArea; + float surfaceArea; + ArrayView vertices; + Array uniqueVertices; + // bounding box + Vector2 majorAxis, minorAxis, minCorner, maxCorner; + // Mesh only + const Array *boundaryEdges; + // UvMeshChart only + Array faces; + + Vector2 &uniqueVertexAt(uint32_t v) { return uniqueVertices.isEmpty() ? vertices[v] : vertices[uniqueVertices[v]]; } + uint32_t uniqueVertexCount() const { return uniqueVertices.isEmpty() ? vertices.length : uniqueVertices.size(); } +}; + +struct AddChartTaskArgs +{ + param::Chart *paramChart; + Chart *chart; // out +}; + +static void runAddChartTask(void *groupUserData, void *taskUserData) +{ + XA_PROFILE_START(packChartsAddChartsThread) + auto boundingBox = (ThreadLocal *)groupUserData; + auto args = (AddChartTaskArgs *)taskUserData; + param::Chart *paramChart = args->paramChart; + XA_PROFILE_START(packChartsAddChartsRestoreTexcoords) + paramChart->restoreTexcoords(); + XA_PROFILE_END(packChartsAddChartsRestoreTexcoords) + Mesh *mesh = paramChart->unifiedMesh(); + Chart *chart = args->chart = XA_NEW(MemTag::Default, Chart); + chart->atlasIndex = -1; + chart->material = 0; + chart->indices = mesh->indices(); + chart->parametricArea = mesh->computeParametricArea(); + if (chart->parametricArea < kAreaEpsilon) { + // When the parametric area is too small we use a rough approximation to prevent divisions by very small numbers. + const Vector2 bounds = paramChart->computeParametricBounds(); + chart->parametricArea = bounds.x * bounds.y; + } + chart->surfaceArea = mesh->computeSurfaceArea(); + chart->vertices = mesh->texcoords(); + chart->boundaryEdges = &mesh->boundaryEdges(); + // Compute bounding box of chart. + BoundingBox2D &bb = boundingBox->get(); + bb.clear(); + for (uint32_t v = 0; v < chart->vertices.length; v++) { + if (mesh->isBoundaryVertex(v)) + bb.appendBoundaryVertex(mesh->texcoord(v)); + } + bb.compute(mesh->texcoords()); + chart->majorAxis = bb.majorAxis; + chart->minorAxis = bb.minorAxis; + chart->minCorner = bb.minCorner; + chart->maxCorner = bb.maxCorner; + XA_PROFILE_END(packChartsAddChartsThread) +} + +struct Atlas +{ + ~Atlas() + { + for (uint32_t i = 0; i < m_atlasImages.size(); i++) { + m_atlasImages[i]->~AtlasImage(); + XA_FREE(m_atlasImages[i]); + } + for (uint32_t i = 0; i < m_bitImages.size(); i++) { + m_bitImages[i]->~BitImage(); + XA_FREE(m_bitImages[i]); + } + for (uint32_t i = 0; i < m_charts.size(); i++) { + m_charts[i]->~Chart(); + XA_FREE(m_charts[i]); + } + } + + uint32_t getWidth() const { return m_width; } + uint32_t getHeight() const { return m_height; } + uint32_t getNumAtlases() const { return m_bitImages.size(); } + float getTexelsPerUnit() const { return m_texelsPerUnit; } + const Chart *getChart(uint32_t index) const { return m_charts[index]; } + uint32_t getChartCount() const { return m_charts.size(); } + const Array &getImages() const { return m_atlasImages; } + float getUtilization(uint32_t atlas) const { return m_utilization[atlas]; } + + void addCharts(TaskScheduler *taskScheduler, param::Atlas *paramAtlas) + { + // Count charts. + uint32_t chartCount = 0; + for (uint32_t i = 0; i < paramAtlas->meshCount(); i++) { + const uint32_t chartGroupsCount = paramAtlas->chartGroupCount(i); + for (uint32_t j = 0; j < chartGroupsCount; j++) { + const param::ChartGroup *chartGroup = paramAtlas->chartGroupAt(i, j); + chartCount += chartGroup->chartCount(); + } + } + if (chartCount == 0) + return; + // Run one task per chart. + ThreadLocal boundingBox; + TaskGroupHandle taskGroup = taskScheduler->createTaskGroup(&boundingBox, chartCount); + Array taskArgs; + taskArgs.resize(chartCount); + uint32_t chartIndex = 0; + for (uint32_t i = 0; i < paramAtlas->meshCount(); i++) { + const uint32_t chartGroupsCount = paramAtlas->chartGroupCount(i); + for (uint32_t j = 0; j < chartGroupsCount; j++) { + const param::ChartGroup *chartGroup = paramAtlas->chartGroupAt(i, j); + const uint32_t count = chartGroup->chartCount(); + for (uint32_t k = 0; k < count; k++) { + AddChartTaskArgs &args = taskArgs[chartIndex]; + args.paramChart = chartGroup->chartAt(k); + Task task; + task.userData = &taskArgs[chartIndex]; + task.func = runAddChartTask; + taskScheduler->run(taskGroup, task); + chartIndex++; + } + } + } + taskScheduler->wait(&taskGroup); + // Get task output. + m_charts.resize(chartCount); + for (uint32_t i = 0; i < chartCount; i++) + m_charts[i] = taskArgs[i].chart; + } + + void addUvMeshCharts(UvMeshInstance *mesh) + { + // Copy texcoords from mesh. + mesh->texcoords.resize(mesh->mesh->texcoords.size()); + memcpy(mesh->texcoords.data(), mesh->mesh->texcoords.data(), mesh->texcoords.size() * sizeof(Vector2)); + BitArray vertexUsed(mesh->texcoords.size()); + BoundingBox2D boundingBox; + for (uint32_t c = 0; c < mesh->mesh->charts.size(); c++) { + UvMeshChart *uvChart = mesh->mesh->charts[c]; + Chart *chart = XA_NEW(MemTag::Default, Chart); + chart->atlasIndex = -1; + chart->material = uvChart->material; + chart->indices = uvChart->indices; + chart->vertices = mesh->texcoords; + chart->boundaryEdges = nullptr; + chart->faces.resize(uvChart->faces.size()); + memcpy(chart->faces.data(), uvChart->faces.data(), sizeof(uint32_t) * uvChart->faces.size()); + // Find unique vertices. + vertexUsed.zeroOutMemory(); + for (uint32_t i = 0; i < chart->indices.length; i++) { + const uint32_t vertex = chart->indices[i]; + if (!vertexUsed.get(vertex)) { + vertexUsed.set(vertex); + chart->uniqueVertices.push_back(vertex); + } + } + // Compute parametric and surface areas. + chart->parametricArea = 0.0f; + for (uint32_t f = 0; f < chart->indices.length / 3; f++) { + const Vector2 &v1 = chart->vertices[chart->indices[f * 3 + 0]]; + const Vector2 &v2 = chart->vertices[chart->indices[f * 3 + 1]]; + const Vector2 &v3 = chart->vertices[chart->indices[f * 3 + 2]]; + chart->parametricArea += fabsf(triangleArea(v1, v2, v3)); + } + chart->parametricArea *= 0.5f; + if (chart->parametricArea < kAreaEpsilon) { + // When the parametric area is too small we use a rough approximation to prevent divisions by very small numbers. + Vector2 minCorner(FLT_MAX, FLT_MAX); + Vector2 maxCorner(-FLT_MAX, -FLT_MAX); + for (uint32_t v = 0; v < chart->uniqueVertexCount(); v++) { + minCorner = min(minCorner, chart->uniqueVertexAt(v)); + maxCorner = max(maxCorner, chart->uniqueVertexAt(v)); + } + const Vector2 bounds = (maxCorner - minCorner) * 0.5f; + chart->parametricArea = bounds.x * bounds.y; + } + XA_DEBUG_ASSERT(isFinite(chart->parametricArea)); + XA_DEBUG_ASSERT(!isNan(chart->parametricArea)); + chart->surfaceArea = chart->parametricArea; // Identical for UV meshes. + // Compute bounding box of chart. + // Using all unique vertices for simplicity, can compute real boundaries if this is too slow. + boundingBox.clear(); + for (uint32_t v = 0; v < chart->uniqueVertexCount(); v++) + boundingBox.appendBoundaryVertex(chart->uniqueVertexAt(v)); + boundingBox.compute(); + chart->majorAxis = boundingBox.majorAxis; + chart->minorAxis = boundingBox.minorAxis; + chart->minCorner = boundingBox.minCorner; + chart->maxCorner = boundingBox.maxCorner; + m_charts.push_back(chart); + } + } + + // Pack charts in the smallest possible rectangle. + bool packCharts(const PackOptions &options, ProgressFunc progressFunc, void *progressUserData) + { + if (progressFunc) { + if (!progressFunc(ProgressCategory::PackCharts, 0, progressUserData)) + return false; + } + const uint32_t chartCount = m_charts.size(); + XA_PRINT("Packing %u charts\n", chartCount); + if (chartCount == 0) { + if (progressFunc) { + if (!progressFunc(ProgressCategory::PackCharts, 100, progressUserData)) + return false; + } + return true; + } + // Estimate resolution and/or texels per unit if not specified. + m_texelsPerUnit = options.texelsPerUnit; + uint32_t resolution = options.resolution > 0 ? options.resolution + options.padding * 2 : 0; + const uint32_t maxResolution = m_texelsPerUnit > 0.0f ? resolution : 0; + if (resolution <= 0 || m_texelsPerUnit <= 0) { + if (resolution <= 0 && m_texelsPerUnit <= 0) + resolution = 1024; + float meshArea = 0; + for (uint32_t c = 0; c < chartCount; c++) + meshArea += m_charts[c]->surfaceArea; + if (resolution <= 0) { + // Estimate resolution based on the mesh surface area and given texel scale. + const float texelCount = max(1.0f, meshArea * square(m_texelsPerUnit) / 0.75f); // Assume 75% utilization. + resolution = max(1u, nextPowerOfTwo(uint32_t(sqrtf(texelCount)))); + } + if (m_texelsPerUnit <= 0) { + // Estimate a suitable texelsPerUnit to fit the given resolution. + const float texelCount = max(1.0f, meshArea / 0.75f); // Assume 75% utilization. + m_texelsPerUnit = sqrtf((resolution * resolution) / texelCount); + XA_PRINT(" Estimating texelsPerUnit as %g\n", m_texelsPerUnit); + } + } + Array chartOrderArray; + chartOrderArray.resize(chartCount); + Array chartExtents; + chartExtents.resize(chartCount); + float minChartPerimeter = FLT_MAX, maxChartPerimeter = 0.0f; + for (uint32_t c = 0; c < chartCount; c++) { + Chart *chart = m_charts[c]; + // Compute chart scale + float scale = 1.0f; + if (chart->parametricArea != 0.0f) { + scale = sqrtf(chart->surfaceArea / chart->parametricArea) * m_texelsPerUnit; + XA_ASSERT(isFinite(scale)); + } + // Translate, rotate and scale vertices. Compute extents. + Vector2 minCorner(FLT_MAX, FLT_MAX); + if (!options.rotateChartsToAxis) { + for (uint32_t i = 0; i < chart->uniqueVertexCount(); i++) + minCorner = min(minCorner, chart->uniqueVertexAt(i)); + } + Vector2 extents(0.0f); + for (uint32_t i = 0; i < chart->uniqueVertexCount(); i++) { + Vector2 &texcoord = chart->uniqueVertexAt(i); + if (options.rotateChartsToAxis) { + const float x = dot(texcoord, chart->majorAxis); + const float y = dot(texcoord, chart->minorAxis); + texcoord.x = x; + texcoord.y = y; + texcoord -= chart->minCorner; + } else { + texcoord -= minCorner; + } + texcoord *= scale; + XA_DEBUG_ASSERT(texcoord.x >= 0.0f && texcoord.y >= 0.0f); + XA_DEBUG_ASSERT(isFinite(texcoord.x) && isFinite(texcoord.y)); + extents = max(extents, texcoord); + } + XA_DEBUG_ASSERT(extents.x >= 0 && extents.y >= 0); + // Scale the charts to use the entire texel area available. So, if the width is 0.1 we could scale it to 1 without increasing the lightmap usage and making a better use of it. In many cases this also improves the look of the seams, since vertices on the chart boundaries have more chances of being aligned with the texel centers. + if (extents.x > 0.0f && extents.y > 0.0f) { + // Block align: align all chart extents to 4x4 blocks, but taking padding and texel center offset into account. + const int blockAlignSizeOffset = options.padding * 2 + 1; + int width = ftoi_ceil(extents.x); + if (options.blockAlign) + width = align(width + blockAlignSizeOffset, 4) - blockAlignSizeOffset; + int height = ftoi_ceil(extents.y); + if (options.blockAlign) + height = align(height + blockAlignSizeOffset, 4) - blockAlignSizeOffset; + for (uint32_t v = 0; v < chart->uniqueVertexCount(); v++) { + Vector2 &texcoord = chart->uniqueVertexAt(v); + texcoord.x = texcoord.x / extents.x * (float)width; + texcoord.y = texcoord.y / extents.y * (float)height; + } + extents.x = (float)width; + extents.y = (float)height; + } + // Limit chart size, either to PackOptions::maxChartSize or maxResolution (if set), whichever is smaller. + // If limiting chart size to maxResolution, print a warning, since that may not be desirable to the user. + uint32_t maxChartSize = options.maxChartSize; + bool warnChartResized = false; + if (maxResolution > 0 && (maxChartSize == 0 || maxResolution < maxChartSize)) { + maxChartSize = maxResolution - options.padding * 2; // Don't include padding. + warnChartResized = true; + } + if (maxChartSize > 0) { + const float realMaxChartSize = (float)maxChartSize - 1.0f; // Aligning to texel centers increases texel footprint by 1. + if (extents.x > realMaxChartSize || extents.y > realMaxChartSize) { + if (warnChartResized) + XA_PRINT(" Resizing chart %u from %gx%g to %ux%u to fit atlas\n", c, extents.x, extents.y, maxChartSize, maxChartSize); + scale = realMaxChartSize / max(extents.x, extents.y); + for (uint32_t i = 0; i < chart->uniqueVertexCount(); i++) { + Vector2 &texcoord = chart->uniqueVertexAt(i); + texcoord = min(texcoord * scale, Vector2(realMaxChartSize)); + } + } + } + // Align to texel centers and add padding offset. + extents.x = extents.y = 0.0f; + for (uint32_t v = 0; v < chart->uniqueVertexCount(); v++) { + Vector2 &texcoord = chart->uniqueVertexAt(v); + texcoord.x += 0.5f + options.padding; + texcoord.y += 0.5f + options.padding; + extents = max(extents, texcoord); + } + if (extents.x > resolution || extents.y > resolution) + XA_PRINT(" Chart %u extents are large (%gx%g)\n", c, extents.x, extents.y); + chartExtents[c] = extents; + chartOrderArray[c] = extents.x + extents.y; // Use perimeter for chart sort key. + minChartPerimeter = min(minChartPerimeter, chartOrderArray[c]); + maxChartPerimeter = max(maxChartPerimeter, chartOrderArray[c]); + } + // Sort charts by perimeter. + m_radix.sort(chartOrderArray); + const uint32_t *ranks = m_radix.ranks(); + // Divide chart perimeter range into buckets. + const float chartPerimeterBucketSize = (maxChartPerimeter - minChartPerimeter) / 16.0f; + uint32_t currentChartBucket = 0; + Array chartStartPositions; // per atlas + chartStartPositions.push_back(Vector2i(0, 0)); + // Pack sorted charts. +#if XA_DEBUG_EXPORT_ATLAS_IMAGES + const bool createImage = true; +#else + const bool createImage = options.createImage; +#endif + // chartImage: result from conservative rasterization + // chartImageBilinear: chartImage plus any texels that would be sampled by bilinear filtering. + // chartImagePadding: either chartImage or chartImageBilinear depending on options, with a dilate filter applied options.padding times. + // Rotated versions swap x and y. + BitImage chartImage, chartImageBilinear, chartImagePadding; + BitImage chartImageRotated, chartImageBilinearRotated, chartImagePaddingRotated; + UniformGrid2 boundaryEdgeGrid; + Array atlasSizes; + atlasSizes.push_back(Vector2i(0, 0)); + int progress = 0; + for (uint32_t i = 0; i < chartCount; i++) { + uint32_t c = ranks[chartCount - i - 1]; // largest chart first + Chart *chart = m_charts[c]; + // @@ Add special cases for dot and line charts. @@ Lightmap rasterizer also needs to handle these special cases. + // @@ We could also have a special case for chart quads. If the quad surface <= 4 texels, align vertices with texel centers and do not add padding. May be very useful for foliage. + // @@ In general we could reduce the padding of all charts by one texel by using a rasterizer that takes into account the 2-texel footprint of the tent bilinear filter. For example, + // if we have a chart that is less than 1 texel wide currently we add one texel to the left and one texel to the right creating a 3-texel-wide bitImage. However, if we know that the + // chart is only 1 texel wide we could align it so that it only touches the footprint of two texels: + // | | <- Touches texels 0, 1 and 2. + // | | <- Only touches texels 0 and 1. + // \ \ / \ / / + // \ X X / + // \ / \ / \ / + // V V V + // 0 1 2 + XA_PROFILE_START(packChartsRasterize) + // Resize and clear (discard = true) chart images. + // Leave room for padding at extents. + chartImage.resize(ftoi_ceil(chartExtents[c].x) + options.padding, ftoi_ceil(chartExtents[c].y) + options.padding, true); + if (options.rotateCharts) + chartImageRotated.resize(chartImage.height(), chartImage.width(), true); + if (options.bilinear) { + chartImageBilinear.resize(chartImage.width(), chartImage.height(), true); + if (options.rotateCharts) + chartImageBilinearRotated.resize(chartImage.height(), chartImage.width(), true); + } + // Rasterize chart faces. + const uint32_t faceCount = chart->indices.length / 3; + for (uint32_t f = 0; f < faceCount; f++) { + Vector2 vertices[3]; + for (uint32_t v = 0; v < 3; v++) + vertices[v] = chart->vertices[chart->indices[f * 3 + v]]; + DrawTriangleCallbackArgs args; + args.chartBitImage = &chartImage; + args.chartBitImageRotated = options.rotateCharts ? &chartImageRotated : nullptr; + raster::drawTriangle(Vector2((float)chartImage.width(), (float)chartImage.height()), vertices, drawTriangleCallback, &args); + } + // Expand chart by pixels sampled by bilinear interpolation. + if (options.bilinear) + bilinearExpand(chart, &chartImage, &chartImageBilinear, options.rotateCharts ? &chartImageBilinearRotated : nullptr, boundaryEdgeGrid); + // Expand chart by padding pixels (dilation). + if (options.padding > 0) { + // Copy into the same BitImage instances for every chart to avoid reallocating BitImage buffers (largest chart is packed first). + XA_PROFILE_START(packChartsDilate) + if (options.bilinear) + chartImageBilinear.copyTo(chartImagePadding); + else + chartImage.copyTo(chartImagePadding); + chartImagePadding.dilate(options.padding); + if (options.rotateCharts) { + if (options.bilinear) + chartImageBilinearRotated.copyTo(chartImagePaddingRotated); + else + chartImageRotated.copyTo(chartImagePaddingRotated); + chartImagePaddingRotated.dilate(options.padding); + } + XA_PROFILE_END(packChartsDilate) + } + XA_PROFILE_END(packChartsRasterize) + // Update brute force bucketing. + if (options.bruteForce) { + if (chartOrderArray[c] > minChartPerimeter && chartOrderArray[c] <= maxChartPerimeter - (chartPerimeterBucketSize * (currentChartBucket + 1))) { + // Moved to a smaller bucket, reset start location. + for (uint32_t j = 0; j < chartStartPositions.size(); j++) + chartStartPositions[j] = Vector2i(0, 0); + currentChartBucket++; + } + } + // Find a location to place the chart in the atlas. + BitImage *chartImageToPack, *chartImageToPackRotated; + if (options.padding > 0) { + chartImageToPack = &chartImagePadding; + chartImageToPackRotated = &chartImagePaddingRotated; + } else if (options.bilinear) { + chartImageToPack = &chartImageBilinear; + chartImageToPackRotated = &chartImageBilinearRotated; + } else { + chartImageToPack = &chartImage; + chartImageToPackRotated = &chartImageRotated; + } + uint32_t currentAtlas = 0; + int best_x = 0, best_y = 0; + int best_cw = 0, best_ch = 0; + int best_r = 0; + for (;;) + { +#if XA_DEBUG + bool firstChartInBitImage = false; +#endif + if (currentAtlas + 1 > m_bitImages.size()) { + // Chart doesn't fit in the current bitImage, create a new one. + BitImage *bi = XA_NEW_ARGS(MemTag::Default, BitImage, resolution, resolution); + m_bitImages.push_back(bi); + atlasSizes.push_back(Vector2i(0, 0)); +#if XA_DEBUG + firstChartInBitImage = true; +#endif + if (createImage) + m_atlasImages.push_back(XA_NEW_ARGS(MemTag::Default, AtlasImage, resolution, resolution)); + // Start positions are per-atlas, so create a new one of those too. + chartStartPositions.push_back(Vector2i(0, 0)); + } + XA_PROFILE_START(packChartsFindLocation) + const bool foundLocation = findChartLocation(options, chartStartPositions[currentAtlas], m_bitImages[currentAtlas], chartImageToPack, chartImageToPackRotated, atlasSizes[currentAtlas].x, atlasSizes[currentAtlas].y, &best_x, &best_y, &best_cw, &best_ch, &best_r, maxResolution); + XA_PROFILE_END(packChartsFindLocation) + XA_DEBUG_ASSERT(!(firstChartInBitImage && !foundLocation)); // Chart doesn't fit in an empty, newly allocated bitImage. Shouldn't happen, since charts are resized if they are too big to fit in the atlas. + if (maxResolution == 0) { + XA_DEBUG_ASSERT(foundLocation); // The atlas isn't limited to a fixed resolution, a chart location should be found on the first attempt. + break; + } + if (foundLocation) + break; + // Chart doesn't fit in the current bitImage, try the next one. + currentAtlas++; + } + // Update brute force start location. + if (options.bruteForce) { + // Reset start location if the chart expanded the atlas. + if (best_x + best_cw > atlasSizes[currentAtlas].x || best_y + best_ch > atlasSizes[currentAtlas].y) { + for (uint32_t j = 0; j < chartStartPositions.size(); j++) + chartStartPositions[j] = Vector2i(0, 0); + } + else { + chartStartPositions[currentAtlas] = Vector2i(best_x, best_y); + } + } + // Update parametric extents. + atlasSizes[currentAtlas].x = max(atlasSizes[currentAtlas].x, best_x + best_cw); + atlasSizes[currentAtlas].y = max(atlasSizes[currentAtlas].y, best_y + best_ch); + // Resize bitImage if necessary. + // If maxResolution > 0, the bitImage is always set to maxResolutionIncludingPadding on creation and doesn't need to be dynamically resized. + if (maxResolution == 0) { + const uint32_t w = (uint32_t)atlasSizes[currentAtlas].x; + const uint32_t h = (uint32_t)atlasSizes[currentAtlas].y; + if (w > m_bitImages[0]->width() || h > m_bitImages[0]->height()) { + m_bitImages[0]->resize(nextPowerOfTwo(w), nextPowerOfTwo(h), false); + if (createImage) + m_atlasImages[0]->resize(m_bitImages[0]->width(), m_bitImages[0]->height()); + } + } else { + XA_DEBUG_ASSERT(atlasSizes[currentAtlas].x <= (int)maxResolution); + XA_DEBUG_ASSERT(atlasSizes[currentAtlas].y <= (int)maxResolution); + } + XA_PROFILE_START(packChartsBlit) + addChart(m_bitImages[currentAtlas], chartImageToPack, chartImageToPackRotated, atlasSizes[currentAtlas].x, atlasSizes[currentAtlas].y, best_x, best_y, best_r); + XA_PROFILE_END(packChartsBlit) + if (createImage) { + if (best_r == 0) { + m_atlasImages[currentAtlas]->addChart(c, &chartImage, options.bilinear ? &chartImageBilinear : nullptr, options.padding > 0 ? &chartImagePadding : nullptr, atlasSizes[currentAtlas].x, atlasSizes[currentAtlas].y, best_x, best_y); + } else { + m_atlasImages[currentAtlas]->addChart(c, &chartImageRotated, options.bilinear ? &chartImageBilinearRotated : nullptr, options.padding > 0 ? &chartImagePaddingRotated : nullptr, atlasSizes[currentAtlas].x, atlasSizes[currentAtlas].y, best_x, best_y); + } +#if XA_DEBUG_EXPORT_ATLAS_IMAGES && XA_DEBUG_EXPORT_ATLAS_IMAGES_PER_CHART + for (uint32_t j = 0; j < m_atlasImages.size(); j++) { + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_atlas_image%02u_chart%04u.tga", j, i); + m_atlasImages[j]->writeTga(filename, (uint32_t)atlasSizes[j].x, (uint32_t)atlasSizes[j].y); + } +#endif + } + chart->atlasIndex = (int32_t)currentAtlas; + // Modify texture coordinates: + // - rotate if the chart should be rotated + // - translate to chart location + // - translate to remove padding from top and left atlas edges (unless block aligned) + for (uint32_t v = 0; v < chart->uniqueVertexCount(); v++) { + Vector2 &texcoord = chart->uniqueVertexAt(v); + Vector2 t = texcoord; + if (best_r) { + XA_DEBUG_ASSERT(options.rotateCharts); + swap(t.x, t.y); + } + texcoord.x = best_x + t.x; + texcoord.y = best_y + t.y; + texcoord.x -= (float)options.padding; + texcoord.y -= (float)options.padding; + XA_ASSERT(texcoord.x >= 0 && texcoord.y >= 0); + XA_ASSERT(isFinite(texcoord.x) && isFinite(texcoord.y)); + } + if (progressFunc) { + const int newProgress = int((i + 1) / (float)chartCount * 100.0f); + if (newProgress != progress) { + progress = newProgress; + if (!progressFunc(ProgressCategory::PackCharts, progress, progressUserData)) + return false; + } + } + } + // Remove padding from outer edges. + if (maxResolution == 0) { + m_width = max(0, atlasSizes[0].x - (int)options.padding * 2); + m_height = max(0, atlasSizes[0].y - (int)options.padding * 2); + } else { + m_width = m_height = maxResolution - (int)options.padding * 2; + } + XA_PRINT(" %dx%d resolution\n", m_width, m_height); + m_utilization.resize(m_bitImages.size()); + for (uint32_t i = 0; i < m_utilization.size(); i++) { + if (m_width == 0 || m_height == 0) + m_utilization[i] = 0.0f; + else { + uint32_t count = 0; + for (uint32_t y = 0; y < m_height; y++) { + for (uint32_t x = 0; x < m_width; x++) + count += m_bitImages[i]->get(x, y); + } + m_utilization[i] = float(count) / (m_width * m_height); + } + if (m_utilization.size() > 1) { + XA_PRINT(" %u: %f%% utilization\n", i, m_utilization[i] * 100.0f); + } + else { + XA_PRINT(" %f%% utilization\n", m_utilization[i] * 100.0f); + } + } +#if XA_DEBUG_EXPORT_ATLAS_IMAGES + for (uint32_t i = 0; i < m_atlasImages.size(); i++) { + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_atlas_image%02u.tga", i); + m_atlasImages[i]->writeTga(filename, m_width, m_height); + } +#endif + if (progressFunc && progress != 100) { + if (!progressFunc(ProgressCategory::PackCharts, 100, progressUserData)) + return false; + } + return true; + } + +private: + bool findChartLocation(const PackOptions &options, const Vector2i &startPosition, const BitImage *atlasBitImage, const BitImage *chartBitImage, const BitImage *chartBitImageRotated, int w, int h, int *best_x, int *best_y, int *best_w, int *best_h, int *best_r, uint32_t maxResolution) + { + const int attempts = 4096; + if (options.bruteForce || attempts >= w * h) + return findChartLocation_bruteForce(options, startPosition, atlasBitImage, chartBitImage, chartBitImageRotated, w, h, best_x, best_y, best_w, best_h, best_r, maxResolution); + return findChartLocation_random(options, atlasBitImage, chartBitImage, chartBitImageRotated, w, h, best_x, best_y, best_w, best_h, best_r, attempts, maxResolution); + } + + bool findChartLocation_bruteForce(const PackOptions &options, const Vector2i &startPosition, const BitImage *atlasBitImage, const BitImage *chartBitImage, const BitImage *chartBitImageRotated, int w, int h, int *best_x, int *best_y, int *best_w, int *best_h, int *best_r, uint32_t maxResolution) + { + const int stepSize = options.blockAlign ? 4 : 1; + int best_metric = INT_MAX; + // Try two different orientations. + for (int r = 0; r < 2; r++) { + int cw = chartBitImage->width(); + int ch = chartBitImage->height(); + if (r == 1) { + if (options.rotateCharts) + swap(cw, ch); + else + break; + } + for (int y = startPosition.y; y <= h + stepSize; y += stepSize) { + if (maxResolution > 0 && y > (int)maxResolution - ch) + break; + for (int x = (y == startPosition.y ? startPosition.x : 0); x <= w + stepSize; x += stepSize) { + if (maxResolution > 0 && x > (int)maxResolution - cw) + break; + // Early out if metric is not better. + const int extentX = max(w, x + cw), extentY = max(h, y + ch); + const int area = extentX * extentY; + const int extents = max(extentX, extentY); + const int metric = extents * extents + area; + if (metric > best_metric) + continue; + // If metric is the same, pick the one closest to the origin. + if (metric == best_metric && max(x, y) >= max(*best_x, *best_y)) + continue; + if (!atlasBitImage->canBlit(r == 1 ? *chartBitImageRotated : *chartBitImage, x, y)) + continue; + best_metric = metric; + *best_x = x; + *best_y = y; + *best_w = cw; + *best_h = ch; + *best_r = r; + if (area == w * h) + return true; // Chart is completely inside, do not look at any other location. + } + } + } + return best_metric != INT_MAX; + } + + bool findChartLocation_random(const PackOptions &options, const BitImage *atlasBitImage, const BitImage *chartBitImage, const BitImage *chartBitImageRotated, int w, int h, int *best_x, int *best_y, int *best_w, int *best_h, int *best_r, int attempts, uint32_t maxResolution) + { + bool result = false; + const int BLOCK_SIZE = 4; + int best_metric = INT_MAX; + for (int i = 0; i < attempts; i++) { + int cw = chartBitImage->width(); + int ch = chartBitImage->height(); + int r = options.rotateCharts ? m_rand.getRange(1) : 0; + if (r == 1) + swap(cw, ch); + // + 1 to extend atlas in case atlas full. We may want to use a higher number to increase probability of extending atlas. + int xRange = w + 1; + int yRange = h + 1; + // Clamp to max resolution. + if (maxResolution > 0) { + xRange = min(xRange, (int)maxResolution - cw); + yRange = min(yRange, (int)maxResolution - ch); + } + int x = m_rand.getRange(xRange); + int y = m_rand.getRange(yRange); + if (options.blockAlign) { + x = align(x, BLOCK_SIZE); + y = align(y, BLOCK_SIZE); + if (maxResolution > 0 && (x > (int)maxResolution - cw || y > (int)maxResolution - ch)) + continue; // Block alignment pushed the chart outside the atlas. + } + // Early out. + int area = max(w, x + cw) * max(h, y + ch); + //int perimeter = max(w, x+cw) + max(h, y+ch); + int extents = max(max(w, x + cw), max(h, y + ch)); + int metric = extents * extents + area; + if (metric > best_metric) { + continue; + } + if (metric == best_metric && min(x, y) > min(*best_x, *best_y)) { + // If metric is the same, pick the one closest to the origin. + continue; + } + if (atlasBitImage->canBlit(r == 1 ? *chartBitImageRotated : *chartBitImage, x, y)) { + result = true; + best_metric = metric; + *best_x = x; + *best_y = y; + *best_w = cw; + *best_h = ch; + *best_r = options.rotateCharts ? r : 0; + if (area == w * h) { + // Chart is completely inside, do not look at any other location. + break; + } + } + } + return result; + } + + void addChart(BitImage *atlasBitImage, const BitImage *chartBitImage, const BitImage *chartBitImageRotated, int atlas_w, int atlas_h, int offset_x, int offset_y, int r) + { + XA_DEBUG_ASSERT(r == 0 || r == 1); + const BitImage *image = r == 0 ? chartBitImage : chartBitImageRotated; + const int w = image->width(); + const int h = image->height(); + for (int y = 0; y < h; y++) { + int yy = y + offset_y; + if (yy >= 0) { + for (int x = 0; x < w; x++) { + int xx = x + offset_x; + if (xx >= 0) { + if (image->get(x, y)) { + if (xx < atlas_w && yy < atlas_h) { + XA_DEBUG_ASSERT(atlasBitImage->get(xx, yy) == false); + atlasBitImage->set(xx, yy); + } + } + } + } + } + } + } + + void bilinearExpand(const Chart *chart, BitImage *source, BitImage *dest, BitImage *destRotated, UniformGrid2 &boundaryEdgeGrid) const + { + boundaryEdgeGrid.reset(chart->vertices, chart->indices); + if (chart->boundaryEdges) { + const uint32_t edgeCount = chart->boundaryEdges->size(); + for (uint32_t i = 0; i < edgeCount; i++) + boundaryEdgeGrid.append((*chart->boundaryEdges)[i]); + } else { + for (uint32_t i = 0; i < chart->indices.length; i++) + boundaryEdgeGrid.append(i); + } + const int xOffsets[] = { -1, 0, 1, -1, 1, -1, 0, 1 }; + const int yOffsets[] = { -1, -1, -1, 0, 0, 1, 1, 1 }; + for (uint32_t y = 0; y < source->height(); y++) { + for (uint32_t x = 0; x < source->width(); x++) { + // Copy pixels from source. + if (source->get(x, y)) + goto setPixel; + // Empty pixel. If none of of the surrounding pixels are set, this pixel can't be sampled by bilinear interpolation. + { + uint32_t s = 0; + for (; s < 8; s++) { + const int sx = (int)x + xOffsets[s]; + const int sy = (int)y + yOffsets[s]; + if (sx < 0 || sy < 0 || sx >= (int)source->width() || sy >= (int)source->height()) + continue; + if (source->get((uint32_t)sx, (uint32_t)sy)) + break; + } + if (s == 8) + continue; + } + { + // If a 2x2 square centered on the pixels centroid intersects the triangle, this pixel will be sampled by bilinear interpolation. + // See "Precomputed Global Illumination in Frostbite (GDC 2018)" page 95 + const Vector2 centroid((float)x + 0.5f, (float)y + 0.5f); + const Vector2 squareVertices[4] = { + Vector2(centroid.x - 1.0f, centroid.y - 1.0f), + Vector2(centroid.x + 1.0f, centroid.y - 1.0f), + Vector2(centroid.x + 1.0f, centroid.y + 1.0f), + Vector2(centroid.x - 1.0f, centroid.y + 1.0f) + }; + for (uint32_t j = 0; j < 4; j++) { + if (boundaryEdgeGrid.intersect(squareVertices[j], squareVertices[(j + 1) % 4], 0.0f)) + goto setPixel; + } + } + continue; + setPixel: + dest->set(x, y); + if (destRotated) + destRotated->set(y, x); + } + } + } + + struct DrawTriangleCallbackArgs + { + BitImage *chartBitImage, *chartBitImageRotated; + }; + + static bool drawTriangleCallback(void *param, int x, int y) + { + auto args = (DrawTriangleCallbackArgs *)param; + args->chartBitImage->set(x, y); + if (args->chartBitImageRotated) + args->chartBitImageRotated->set(y, x); + return true; + } + + Array m_atlasImages; + Array m_utilization; + Array m_bitImages; + Array m_charts; + RadixSort m_radix; + uint32_t m_width = 0; + uint32_t m_height = 0; + float m_texelsPerUnit = 0.0f; + KISSRng m_rand; +}; + +} // namespace pack +} // namespace internal + +// Used to map triangulated polygons back to polygons. +struct MeshPolygonMapping +{ + internal::Array faceVertexCount; // Copied from MeshDecl::faceVertexCount. + internal::Array triangleToPolygonMap; // Triangle index (mesh face index) to polygon index. + internal::Array triangleToPolygonIndicesMap; // Triangle indices to polygon indices. +}; + +struct Context +{ + Atlas atlas; + internal::Progress *addMeshProgress = nullptr; + internal::TaskGroupHandle addMeshTaskGroup; + internal::param::Atlas paramAtlas; + ProgressFunc progressFunc = nullptr; + void *progressUserData = nullptr; + internal::TaskScheduler *taskScheduler; + internal::Array meshes; + internal::Array meshPolygonMappings; + internal::Array uvMeshes; + internal::Array uvMeshInstances; + bool uvMeshChartsComputed = false; +}; + +Atlas *Create() +{ + Context *ctx = XA_NEW(internal::MemTag::Default, Context); + memset(&ctx->atlas, 0, sizeof(Atlas)); + ctx->taskScheduler = XA_NEW(internal::MemTag::Default, internal::TaskScheduler); + return &ctx->atlas; +} + +static void DestroyOutputMeshes(Context *ctx) +{ + if (!ctx->atlas.meshes) + return; + for (int i = 0; i < (int)ctx->atlas.meshCount; i++) { + Mesh &mesh = ctx->atlas.meshes[i]; + if (mesh.chartArray) { + for (uint32_t j = 0; j < mesh.chartCount; j++) { + if (mesh.chartArray[j].faceArray) + XA_FREE(mesh.chartArray[j].faceArray); + } + XA_FREE(mesh.chartArray); + } + if (mesh.vertexArray) + XA_FREE(mesh.vertexArray); + if (mesh.indexArray) + XA_FREE(mesh.indexArray); + } + XA_FREE(ctx->atlas.meshes); + ctx->atlas.meshes = nullptr; +} + +void Destroy(Atlas *atlas) +{ + XA_DEBUG_ASSERT(atlas); + Context *ctx = (Context *)atlas; + if (atlas->utilization) + XA_FREE(atlas->utilization); + if (atlas->image) + XA_FREE(atlas->image); + DestroyOutputMeshes(ctx); + if (ctx->addMeshProgress) { + ctx->addMeshProgress->cancel = true; + AddMeshJoin(atlas); // frees addMeshProgress + } + ctx->taskScheduler->~TaskScheduler(); + XA_FREE(ctx->taskScheduler); + for (uint32_t i = 0; i < ctx->meshes.size(); i++) { + internal::Mesh *mesh = ctx->meshes[i]; + mesh->~Mesh(); + XA_FREE(mesh); + } + for (uint32_t i = 0; i < ctx->meshPolygonMappings.size(); i++) { + MeshPolygonMapping *mapping = ctx->meshPolygonMappings[i]; + if (mapping) { + mapping->~MeshPolygonMapping(); + XA_FREE(mapping); + } + } + for (uint32_t i = 0; i < ctx->uvMeshes.size(); i++) { + internal::UvMesh *mesh = ctx->uvMeshes[i]; + for (uint32_t j = 0; j < mesh->charts.size(); j++) { + mesh->charts[j]->~UvMeshChart(); + XA_FREE(mesh->charts[j]); + } + mesh->~UvMesh(); + XA_FREE(mesh); + } + for (uint32_t i = 0; i < ctx->uvMeshInstances.size(); i++) { + internal::UvMeshInstance *mesh = ctx->uvMeshInstances[i]; + mesh->~UvMeshInstance(); + XA_FREE(mesh); + } + ctx->~Context(); + XA_FREE(ctx); +#if XA_DEBUG_HEAP + internal::ReportLeaks(); +#endif +} + +static void runAddMeshTask(void *groupUserData, void *taskUserData) +{ + XA_PROFILE_START(addMeshThread) + auto ctx = (Context *)groupUserData; + auto mesh = (internal::Mesh *)taskUserData; + internal::Progress *progress = ctx->addMeshProgress; + if (progress->cancel) { + XA_PROFILE_END(addMeshThread) + return; + } + XA_PROFILE_START(addMeshCreateColocals) + mesh->createColocals(); + XA_PROFILE_END(addMeshCreateColocals) + if (progress->cancel) { + XA_PROFILE_END(addMeshThread) + return; + } + progress->increment(1); + XA_PROFILE_END(addMeshThread) +} + +static internal::Vector3 DecodePosition(const MeshDecl &meshDecl, uint32_t index) +{ + XA_DEBUG_ASSERT(meshDecl.vertexPositionData); + XA_DEBUG_ASSERT(meshDecl.vertexPositionStride > 0); + return *((const internal::Vector3 *)&((const uint8_t *)meshDecl.vertexPositionData)[meshDecl.vertexPositionStride * index]); +} + +static internal::Vector3 DecodeNormal(const MeshDecl &meshDecl, uint32_t index) +{ + XA_DEBUG_ASSERT(meshDecl.vertexNormalData); + XA_DEBUG_ASSERT(meshDecl.vertexNormalStride > 0); + return *((const internal::Vector3 *)&((const uint8_t *)meshDecl.vertexNormalData)[meshDecl.vertexNormalStride * index]); +} + +static internal::Vector2 DecodeUv(const MeshDecl &meshDecl, uint32_t index) +{ + XA_DEBUG_ASSERT(meshDecl.vertexUvData); + XA_DEBUG_ASSERT(meshDecl.vertexUvStride > 0); + return *((const internal::Vector2 *)&((const uint8_t *)meshDecl.vertexUvData)[meshDecl.vertexUvStride * index]); +} + +static uint32_t DecodeIndex(IndexFormat format, const void *indexData, int32_t offset, uint32_t i) +{ + XA_DEBUG_ASSERT(indexData); + if (format == IndexFormat::UInt16) + return uint16_t((int32_t)((const uint16_t *)indexData)[i] + offset); + return uint32_t((int32_t)((const uint32_t *)indexData)[i] + offset); +} + +AddMeshError AddMesh(Atlas *atlas, const MeshDecl &meshDecl, uint32_t meshCountHint) +{ + XA_DEBUG_ASSERT(atlas); + if (!atlas) { + XA_PRINT_WARNING("AddMesh: atlas is null.\n"); + return AddMeshError::Error; + } + Context *ctx = (Context *)atlas; + if (!ctx->uvMeshes.isEmpty()) { + XA_PRINT_WARNING("AddMesh: Meshes and UV meshes cannot be added to the same atlas.\n"); + return AddMeshError::Error; + } +#if XA_PROFILE + if (ctx->meshes.isEmpty()) + internal::s_profile.addMeshRealStart = std::chrono::high_resolution_clock::now(); +#endif + // Don't know how many times AddMesh will be called, so progress needs to adjusted each time. + if (!ctx->addMeshProgress) { + ctx->addMeshProgress = XA_NEW_ARGS(internal::MemTag::Default, internal::Progress, ProgressCategory::AddMesh, ctx->progressFunc, ctx->progressUserData, 1); + } + else { + ctx->addMeshProgress->setMaxValue(internal::max(ctx->meshes.size() + 1, meshCountHint)); + } + XA_PROFILE_START(addMeshCopyData) + const bool hasIndices = meshDecl.indexCount > 0; + const uint32_t indexCount = hasIndices ? meshDecl.indexCount : meshDecl.vertexCount; + uint32_t faceCount = indexCount / 3; + if (meshDecl.faceVertexCount) { + faceCount = meshDecl.faceCount; + XA_PRINT("Adding mesh %d: %u vertices, %u polygons\n", ctx->meshes.size(), meshDecl.vertexCount, faceCount); + for (uint32_t f = 0; f < faceCount; f++) { + if (meshDecl.faceVertexCount[f] < 3) + return AddMeshError::InvalidFaceVertexCount; + } + } else { + XA_PRINT("Adding mesh %d: %u vertices, %u triangles\n", ctx->meshes.size(), meshDecl.vertexCount, faceCount); + // Expecting triangle faces unless otherwise specified. + if ((indexCount % 3) != 0) + return AddMeshError::InvalidIndexCount; + } + uint32_t meshFlags = internal::MeshFlags::HasIgnoredFaces; + if (meshDecl.vertexNormalData) + meshFlags |= internal::MeshFlags::HasNormals; + if (meshDecl.faceMaterialData) + meshFlags |= internal::MeshFlags::HasMaterials; + internal::Mesh *mesh = XA_NEW_ARGS(internal::MemTag::Mesh, internal::Mesh, meshDecl.epsilon, meshDecl.vertexCount, indexCount / 3, meshFlags, ctx->meshes.size()); + for (uint32_t i = 0; i < meshDecl.vertexCount; i++) { + internal::Vector3 normal(0.0f); + internal::Vector2 texcoord(0.0f); + if (meshDecl.vertexNormalData) + normal = DecodeNormal(meshDecl, i); + if (meshDecl.vertexUvData) + texcoord = DecodeUv(meshDecl, i); + mesh->addVertex(DecodePosition(meshDecl, i), normal, texcoord); + } + MeshPolygonMapping *meshPolygonMapping = nullptr; + if (meshDecl.faceVertexCount) { + meshPolygonMapping = XA_NEW(internal::MemTag::Default, MeshPolygonMapping); + // Copy MeshDecl::faceVertexCount so it can be used later when building output meshes. + meshPolygonMapping->faceVertexCount.copyFrom(meshDecl.faceVertexCount, meshDecl.faceCount); + // There should be at least as many triangles as polygons. + meshPolygonMapping->triangleToPolygonMap.reserve(meshDecl.faceCount); + meshPolygonMapping->triangleToPolygonIndicesMap.reserve(meshDecl.indexCount); + } + const uint32_t kMaxWarnings = 50; + uint32_t warningCount = 0; + internal::Array triIndices; + internal::Triangulator triangulator; + for (uint32_t face = 0; face < faceCount; face++) { + // Decode face indices. + const uint32_t faceVertexCount = meshDecl.faceVertexCount ? (uint32_t)meshDecl.faceVertexCount[face] : 3; + uint32_t polygon[UINT8_MAX]; + for (uint32_t i = 0; i < faceVertexCount; i++) { + if (hasIndices) { + polygon[i] = DecodeIndex(meshDecl.indexFormat, meshDecl.indexData, meshDecl.indexOffset, face * faceVertexCount + i); + // Check if any index is out of range. + if (polygon[i] >= meshDecl.vertexCount) { + mesh->~Mesh(); + XA_FREE(mesh); + return AddMeshError::IndexOutOfRange; + } + } else { + polygon[i] = face * faceVertexCount + i; + } + } + // Ignore faces with degenerate or zero length edges. + bool ignore = false; + for (uint32_t i = 0; i < faceVertexCount; i++) { + const uint32_t index1 = polygon[i]; + const uint32_t index2 = polygon[(i + 1) % 3]; + if (index1 == index2) { + ignore = true; + if (++warningCount <= kMaxWarnings) + XA_PRINT(" Degenerate edge: index %d, index %d\n", index1, index2); + break; + } + const internal::Vector3 &pos1 = mesh->position(index1); + const internal::Vector3 &pos2 = mesh->position(index2); + if (internal::length(pos2 - pos1) <= 0.0f) { + ignore = true; + if (++warningCount <= kMaxWarnings) + XA_PRINT(" Zero length edge: index %d position (%g %g %g), index %d position (%g %g %g)\n", index1, pos1.x, pos1.y, pos1.z, index2, pos2.x, pos2.y, pos2.z); + break; + } + } + // Ignore faces with any nan vertex attributes. + if (!ignore) { + for (uint32_t i = 0; i < faceVertexCount; i++) { + const internal::Vector3 &pos = mesh->position(polygon[i]); + if (internal::isNan(pos.x) || internal::isNan(pos.y) || internal::isNan(pos.z)) { + if (++warningCount <= kMaxWarnings) + XA_PRINT(" NAN position in face: %d\n", face); + ignore = true; + break; + } + if (meshDecl.vertexNormalData) { + const internal::Vector3 &normal = mesh->normal(polygon[i]); + if (internal::isNan(normal.x) || internal::isNan(normal.y) || internal::isNan(normal.z)) { + if (++warningCount <= kMaxWarnings) + XA_PRINT(" NAN normal in face: %d\n", face); + ignore = true; + break; + } + } + if (meshDecl.vertexUvData) { + const internal::Vector2 &uv = mesh->texcoord(polygon[i]); + if (internal::isNan(uv.x) || internal::isNan(uv.y)) { + if (++warningCount <= kMaxWarnings) + XA_PRINT(" NAN texture coordinate in face: %d\n", face); + ignore = true; + break; + } + } + } + } + // Triangulate if necessary. + triIndices.clear(); + if (faceVertexCount == 3) { + triIndices.push_back(polygon[0]); + triIndices.push_back(polygon[1]); + triIndices.push_back(polygon[2]); + } else { + triangulator.triangulatePolygon(mesh->positions(), internal::ConstArrayView(polygon, faceVertexCount), triIndices); + } + // Check for zero area faces. + if (!ignore) { + for (uint32_t i = 0; i < triIndices.size(); i += 3) { + const internal::Vector3 &a = mesh->position(triIndices[i + 0]); + const internal::Vector3 &b = mesh->position(triIndices[i + 1]); + const internal::Vector3 &c = mesh->position(triIndices[i + 2]); + const float area = internal::length(internal::cross(b - a, c - a)) * 0.5f; + if (area <= internal::kAreaEpsilon) { + ignore = true; + if (++warningCount <= kMaxWarnings) + XA_PRINT(" Zero area face: %d, area is %f\n", face, area); + break; + } + } + } + // User face ignore. + if (meshDecl.faceIgnoreData && meshDecl.faceIgnoreData[face]) + ignore = true; + // User material. + uint32_t material = UINT32_MAX; + if (meshDecl.faceMaterialData) + material = meshDecl.faceMaterialData[face]; + // Add the face(s). + for (uint32_t i = 0; i < triIndices.size(); i += 3) { + mesh->addFace(&triIndices[i], ignore, material); + if (meshPolygonMapping) + meshPolygonMapping->triangleToPolygonMap.push_back(face); + } + if (meshPolygonMapping) { + for (uint32_t i = 0; i < triIndices.size(); i++) + meshPolygonMapping->triangleToPolygonIndicesMap.push_back(triIndices[i]); + } + } + if (warningCount > kMaxWarnings) + XA_PRINT(" %u additional warnings truncated\n", warningCount - kMaxWarnings); + XA_PROFILE_END(addMeshCopyData) + ctx->meshes.push_back(mesh); + ctx->meshPolygonMappings.push_back(meshPolygonMapping); + ctx->paramAtlas.addMesh(mesh); + if (ctx->addMeshTaskGroup.value == UINT32_MAX) + ctx->addMeshTaskGroup = ctx->taskScheduler->createTaskGroup(ctx); + internal::Task task; + task.userData = mesh; + task.func = runAddMeshTask; + ctx->taskScheduler->run(ctx->addMeshTaskGroup, task); + return AddMeshError::Success; +} + +void AddMeshJoin(Atlas *atlas) +{ + XA_DEBUG_ASSERT(atlas); + if (!atlas) { + XA_PRINT_WARNING("AddMeshJoin: atlas is null.\n"); + return; + } + Context *ctx = (Context *)atlas; + if (!ctx->uvMeshes.isEmpty()) { +#if XA_PROFILE + XA_PRINT("Added %u UV meshes\n", ctx->uvMeshes.size()); + internal::s_profile.addMeshReal = uint64_t(std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - internal::s_profile.addMeshRealStart).count()); +#endif + XA_PROFILE_PRINT_AND_RESET(" Total: ", addMeshReal) + XA_PROFILE_PRINT_AND_RESET(" Copy data: ", addMeshCopyData) +#if XA_PROFILE_ALLOC + XA_PROFILE_PRINT_AND_RESET(" Alloc: ", alloc) +#endif + XA_PRINT_MEM_USAGE + } else { + if (!ctx->addMeshProgress) + return; + ctx->taskScheduler->wait(&ctx->addMeshTaskGroup); + ctx->addMeshProgress->~Progress(); + XA_FREE(ctx->addMeshProgress); + ctx->addMeshProgress = nullptr; +#if XA_PROFILE + XA_PRINT("Added %u meshes\n", ctx->meshes.size()); + internal::s_profile.addMeshReal = uint64_t(std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - internal::s_profile.addMeshRealStart).count()); +#endif + XA_PROFILE_PRINT_AND_RESET(" Total (real): ", addMeshReal) + XA_PROFILE_PRINT_AND_RESET(" Copy data: ", addMeshCopyData) + XA_PROFILE_PRINT_AND_RESET(" Total (thread): ", addMeshThread) + XA_PROFILE_PRINT_AND_RESET(" Create colocals: ", addMeshCreateColocals) +#if XA_PROFILE_ALLOC + XA_PROFILE_PRINT_AND_RESET(" Alloc: ", alloc) +#endif + XA_PRINT_MEM_USAGE +#if XA_DEBUG_EXPORT_OBJ_FACE_GROUPS + internal::param::s_faceGroupsCurrentVertex = 0; +#endif + } +} + +AddMeshError AddUvMesh(Atlas *atlas, const UvMeshDecl &decl) +{ + XA_DEBUG_ASSERT(atlas); + if (!atlas) { + XA_PRINT_WARNING("AddUvMesh: atlas is null.\n"); + return AddMeshError::Error; + } + Context *ctx = (Context *)atlas; + if (!ctx->meshes.isEmpty()) { + XA_PRINT_WARNING("AddUvMesh: Meshes and UV meshes cannot be added to the same atlas.\n"); + return AddMeshError::Error; + } +#if XA_PROFILE + if (ctx->uvMeshInstances.isEmpty()) + internal::s_profile.addMeshRealStart = std::chrono::high_resolution_clock::now(); +#endif + XA_PROFILE_START(addMeshCopyData) + const bool hasIndices = decl.indexCount > 0; + const uint32_t indexCount = hasIndices ? decl.indexCount : decl.vertexCount; + XA_PRINT("Adding UV mesh %d: %u vertices, %u triangles\n", ctx->uvMeshes.size(), decl.vertexCount, indexCount / 3); + // Expecting triangle faces. + if ((indexCount % 3) != 0) + return AddMeshError::InvalidIndexCount; + if (hasIndices) { + // Check if any index is out of range. + for (uint32_t i = 0; i < indexCount; i++) { + const uint32_t index = DecodeIndex(decl.indexFormat, decl.indexData, decl.indexOffset, i); + if (index >= decl.vertexCount) + return AddMeshError::IndexOutOfRange; + } + } + // Create a mesh instance. + internal::UvMeshInstance *meshInstance = XA_NEW(internal::MemTag::Default, internal::UvMeshInstance); + meshInstance->mesh = nullptr; + ctx->uvMeshInstances.push_back(meshInstance); + // See if this is an instance of an already existing mesh. + internal::UvMesh *mesh = nullptr; + for (uint32_t m = 0; m < ctx->uvMeshes.size(); m++) { + if (memcmp(&ctx->uvMeshes[m]->decl, &decl, sizeof(UvMeshDecl)) == 0) { + mesh = ctx->uvMeshes[m]; + XA_PRINT(" instance of a previous UV mesh\n"); + break; + } + } + if (!mesh) { + // Copy geometry to mesh. + mesh = XA_NEW(internal::MemTag::Default, internal::UvMesh); + ctx->uvMeshes.push_back(mesh); + mesh->decl = decl; + if (decl.faceMaterialData) { + mesh->faceMaterials.resize(decl.indexCount / 3); + memcpy(mesh->faceMaterials.data(), decl.faceMaterialData, mesh->faceMaterials.size() * sizeof(uint32_t)); + } + mesh->indices.resize(decl.indexCount); + for (uint32_t i = 0; i < indexCount; i++) + mesh->indices[i] = hasIndices ? DecodeIndex(decl.indexFormat, decl.indexData, decl.indexOffset, i) : i; + mesh->texcoords.resize(decl.vertexCount); + for (uint32_t i = 0; i < decl.vertexCount; i++) + mesh->texcoords[i] = *((const internal::Vector2 *)&((const uint8_t *)decl.vertexUvData)[decl.vertexStride * i]); + // Validate. + mesh->faceIgnore.resize(decl.indexCount / 3); + mesh->faceIgnore.zeroOutMemory(); + const uint32_t kMaxWarnings = 50; + uint32_t warningCount = 0; + for (uint32_t f = 0; f < indexCount / 3; f++) { + bool ignore = false; + uint32_t tri[3]; + for (uint32_t i = 0; i < 3; i++) + tri[i] = mesh->indices[f * 3 + i]; + // Check for nan UVs. + for (uint32_t i = 0; i < 3; i++) { + const uint32_t vertex = tri[i]; + if (internal::isNan(mesh->texcoords[vertex].x) || internal::isNan(mesh->texcoords[vertex].y)) { + ignore = true; + if (++warningCount <= kMaxWarnings) + XA_PRINT(" NAN texture coordinate in vertex %u\n", vertex); + break; + } + } + // Check for zero area faces. + if (!ignore) { + const internal::Vector2 &v1 = mesh->texcoords[tri[0]]; + const internal::Vector2 &v2 = mesh->texcoords[tri[1]]; + const internal::Vector2 &v3 = mesh->texcoords[tri[2]]; + const float area = fabsf(((v2.x - v1.x) * (v3.y - v1.y) - (v3.x - v1.x) * (v2.y - v1.y)) * 0.5f); + if (area <= internal::kAreaEpsilon) { + ignore = true; + if (++warningCount <= kMaxWarnings) + XA_PRINT(" Zero area face: %d, indices (%d %d %d), area is %f\n", f, tri[0], tri[1], tri[2], area); + } + } + if (ignore) + mesh->faceIgnore.set(f); + } + if (warningCount > kMaxWarnings) + XA_PRINT(" %u additional warnings truncated\n", warningCount - kMaxWarnings); + } + meshInstance->mesh = mesh; + XA_PROFILE_END(addMeshCopyData) + return AddMeshError::Success; +} + +void ComputeCharts(Atlas *atlas, ChartOptions options) +{ + if (!atlas) { + XA_PRINT_WARNING("ComputeCharts: atlas is null.\n"); + return; + } + Context *ctx = (Context *)atlas; + AddMeshJoin(atlas); + if (ctx->meshes.isEmpty() && ctx->uvMeshInstances.isEmpty()) { + XA_PRINT_WARNING("ComputeCharts: No meshes. Call AddMesh or AddUvMesh first.\n"); + return; + } + // Reset atlas state. This function may be called multiple times, or again after PackCharts. + if (atlas->utilization) + XA_FREE(atlas->utilization); + if (atlas->image) + XA_FREE(atlas->image); + DestroyOutputMeshes(ctx); + memset(&ctx->atlas, 0, sizeof(Atlas)); + XA_PRINT("Computing charts\n"); + if (!ctx->meshes.isEmpty()) { + if (!ctx->paramAtlas.computeCharts(ctx->taskScheduler, options, ctx->progressFunc, ctx->progressUserData)) { + XA_PRINT(" Cancelled by user\n"); + return; + } + uint32_t chartsWithTJunctionsCount = 0, tJunctionCount = 0, orthoChartsCount = 0, planarChartsCount = 0, lscmChartsCount = 0, piecewiseChartsCount = 0, originalUvChartsCount = 0; + uint32_t chartCount = 0; + const uint32_t meshCount = ctx->meshes.size(); + for (uint32_t i = 0; i < meshCount; i++) { + for (uint32_t j = 0; j < ctx->paramAtlas.chartGroupCount(i); j++) { + const internal::param::ChartGroup *chartGroup = ctx->paramAtlas.chartGroupAt(i, j); + for (uint32_t k = 0; k < chartGroup->chartCount(); k++) { + const internal::param::Chart *chart = chartGroup->chartAt(k); + tJunctionCount += chart->tjunctionCount(); + if (chart->tjunctionCount() > 0) + chartsWithTJunctionsCount++; + if (chart->type() == ChartType::Planar) + planarChartsCount++; + else if (chart->type() == ChartType::Ortho) + orthoChartsCount++; + else if (chart->type() == ChartType::LSCM) + lscmChartsCount++; + else if (chart->type() == ChartType::Piecewise) + piecewiseChartsCount++; + if (chart->generatorType() == internal::segment::ChartGeneratorType::OriginalUv) + originalUvChartsCount++; + } + chartCount += chartGroup->chartCount(); + } + } + if (tJunctionCount > 0) + XA_PRINT(" %u t-junctions found in %u charts\n", tJunctionCount, chartsWithTJunctionsCount); + XA_PRINT(" %u charts\n", chartCount); + XA_PRINT(" %u planar, %u ortho, %u LSCM, %u piecewise\n", planarChartsCount, orthoChartsCount, lscmChartsCount, piecewiseChartsCount); + if (originalUvChartsCount > 0) + XA_PRINT(" %u with original UVs\n", originalUvChartsCount); + uint32_t chartIndex = 0, invalidParamCount = 0; + for (uint32_t i = 0; i < meshCount; i++) { + for (uint32_t j = 0; j < ctx->paramAtlas.chartGroupCount(i); j++) { + const internal::param::ChartGroup *chartGroup = ctx->paramAtlas.chartGroupAt(i, j); + for (uint32_t k = 0; k < chartGroup->chartCount(); k++) { + internal::param::Chart *chart = chartGroup->chartAt(k); + const internal::param::Quality &quality = chart->quality(); +#if XA_DEBUG_EXPORT_OBJ_CHARTS_AFTER_PARAMETERIZATION + { + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_chart_%03u_after_parameterization.obj", chartIndex); + chart->unifiedMesh()->writeObjFile(filename); + } +#endif + const char *type = "LSCM"; + if (chart->type() == ChartType::Planar) + type = "planar"; + else if (chart->type() == ChartType::Ortho) + type = "ortho"; + else if (chart->type() == ChartType::Piecewise) + type = "piecewise"; + if (chart->isInvalid()) { + if (quality.boundaryIntersection) { + XA_PRINT_WARNING(" Chart %u (mesh %u, group %u, id %u) (%s): invalid parameterization, self-intersecting boundary.\n", chartIndex, i, j, k, type); + } + if (quality.flippedTriangleCount > 0) { + XA_PRINT_WARNING(" Chart %u (mesh %u, group %u, id %u) (%s): invalid parameterization, %u / %u flipped triangles.\n", chartIndex, i, j, k, type, quality.flippedTriangleCount, quality.totalTriangleCount); + } + if (quality.zeroAreaTriangleCount > 0) { + XA_PRINT_WARNING(" Chart %u (mesh %u, group %u, id %u) (%s): invalid parameterization, %u / %u zero area triangles.\n", chartIndex, i, j, k, type, quality.zeroAreaTriangleCount, quality.totalTriangleCount); + } + invalidParamCount++; +#if XA_DEBUG_EXPORT_OBJ_INVALID_PARAMETERIZATION + char filename[256]; + XA_SPRINTF(filename, sizeof(filename), "debug_chart_%03u_invalid_parameterization.obj", chartIndex); + const internal::Mesh *mesh = chart->unifiedMesh(); + FILE *file; + XA_FOPEN(file, filename, "w"); + if (file) { + mesh->writeObjVertices(file); + fprintf(file, "s off\n"); + fprintf(file, "o object\n"); + for (uint32_t f = 0; f < mesh->faceCount(); f++) + mesh->writeObjFace(file, f); + if (!chart->paramFlippedFaces().isEmpty()) { + fprintf(file, "o flipped_faces\n"); + for (uint32_t f = 0; f < chart->paramFlippedFaces().size(); f++) + mesh->writeObjFace(file, chart->paramFlippedFaces()[f]); + } + mesh->writeObjBoundaryEges(file); + fclose(file); + } +#endif + } + chartIndex++; + } + } + } + if (invalidParamCount > 0) + XA_PRINT_WARNING(" %u charts with invalid parameterizations\n", invalidParamCount); +#if XA_PROFILE + XA_PRINT(" Chart groups\n"); + uint32_t chartGroupCount = 0; + for (uint32_t i = 0; i < meshCount; i++) { +#if 0 + XA_PRINT(" Mesh %u: %u chart groups\n", i, ctx->paramAtlas.chartGroupCount(i)); +#endif + chartGroupCount += ctx->paramAtlas.chartGroupCount(i); + } + XA_PRINT(" %u total\n", chartGroupCount); +#endif + XA_PROFILE_PRINT_AND_RESET(" Compute charts total (real): ", computeChartsReal) + XA_PROFILE_PRINT_AND_RESET(" Compute charts total (thread): ", computeChartsThread) + XA_PROFILE_PRINT_AND_RESET(" Create face groups: ", createFaceGroups) + XA_PROFILE_PRINT_AND_RESET(" Extract invalid mesh geometry: ", extractInvalidMeshGeometry) + XA_PROFILE_PRINT_AND_RESET(" Chart group compute charts (real): ", chartGroupComputeChartsReal) + XA_PROFILE_PRINT_AND_RESET(" Chart group compute charts (thread): ", chartGroupComputeChartsThread) + XA_PROFILE_PRINT_AND_RESET(" Create chart group mesh: ", createChartGroupMesh) + XA_PROFILE_PRINT_AND_RESET(" Create colocals: ", createChartGroupMeshColocals) + XA_PROFILE_PRINT_AND_RESET(" Create boundaries: ", createChartGroupMeshBoundaries) + XA_PROFILE_PRINT_AND_RESET(" Build atlas: ", buildAtlas) + XA_PROFILE_PRINT_AND_RESET(" Init: ", buildAtlasInit) + XA_PROFILE_PRINT_AND_RESET(" Planar charts: ", planarCharts) + if (options.useInputMeshUvs) { + XA_PROFILE_PRINT_AND_RESET(" Original UV charts: ", originalUvCharts) + } + XA_PROFILE_PRINT_AND_RESET(" Clustered charts: ", clusteredCharts) + XA_PROFILE_PRINT_AND_RESET(" Place seeds: ", clusteredChartsPlaceSeeds) + XA_PROFILE_PRINT_AND_RESET(" Boundary intersection: ", clusteredChartsPlaceSeedsBoundaryIntersection) + XA_PROFILE_PRINT_AND_RESET(" Relocate seeds: ", clusteredChartsRelocateSeeds) + XA_PROFILE_PRINT_AND_RESET(" Reset: ", clusteredChartsReset) + XA_PROFILE_PRINT_AND_RESET(" Grow: ", clusteredChartsGrow) + XA_PROFILE_PRINT_AND_RESET(" Boundary intersection: ", clusteredChartsGrowBoundaryIntersection) + XA_PROFILE_PRINT_AND_RESET(" Merge: ", clusteredChartsMerge) + XA_PROFILE_PRINT_AND_RESET(" Fill holes: ", clusteredChartsFillHoles) + XA_PROFILE_PRINT_AND_RESET(" Copy chart faces: ", copyChartFaces) + XA_PROFILE_PRINT_AND_RESET(" Create chart mesh and parameterize (real): ", createChartMeshAndParameterizeReal) + XA_PROFILE_PRINT_AND_RESET(" Create chart mesh and parameterize (thread): ", createChartMeshAndParameterizeThread) + XA_PROFILE_PRINT_AND_RESET(" Create chart mesh: ", createChartMesh) + XA_PROFILE_PRINT_AND_RESET(" Parameterize charts: ", parameterizeCharts) + XA_PROFILE_PRINT_AND_RESET(" Orthogonal: ", parameterizeChartsOrthogonal) + XA_PROFILE_PRINT_AND_RESET(" LSCM: ", parameterizeChartsLSCM) + XA_PROFILE_PRINT_AND_RESET(" Recompute: ", parameterizeChartsRecompute) + XA_PROFILE_PRINT_AND_RESET(" Piecewise: ", parameterizeChartsPiecewise) + XA_PROFILE_PRINT_AND_RESET(" Boundary intersection: ", parameterizeChartsPiecewiseBoundaryIntersection) + XA_PROFILE_PRINT_AND_RESET(" Evaluate quality: ", parameterizeChartsEvaluateQuality) +#if XA_PROFILE_ALLOC + XA_PROFILE_PRINT_AND_RESET(" Alloc: ", alloc) +#endif + XA_PRINT_MEM_USAGE + } else { + XA_PROFILE_START(computeChartsReal) + if (!internal::segment::computeUvMeshCharts(ctx->taskScheduler, ctx->uvMeshes, ctx->progressFunc, ctx->progressUserData)) { + XA_PRINT(" Cancelled by user\n"); + return; + } + XA_PROFILE_END(computeChartsReal) + ctx->uvMeshChartsComputed = true; + // Count charts. + uint32_t chartCount = 0; + const uint32_t meshCount = ctx->uvMeshes.size(); + for (uint32_t i = 0; i < meshCount; i++) + chartCount += ctx->uvMeshes[i]->charts.size(); + XA_PRINT(" %u charts\n", chartCount); + XA_PROFILE_PRINT_AND_RESET(" Total (real): ", computeChartsReal) + XA_PROFILE_PRINT_AND_RESET(" Total (thread): ", computeChartsThread) + } +#if XA_PROFILE_ALLOC + XA_PROFILE_PRINT_AND_RESET(" Alloc: ", alloc) +#endif + XA_PRINT_MEM_USAGE +} + +void PackCharts(Atlas *atlas, PackOptions packOptions) +{ + // Validate arguments and context state. + if (!atlas) { + XA_PRINT_WARNING("PackCharts: atlas is null.\n"); + return; + } + Context *ctx = (Context *)atlas; + if (ctx->meshes.isEmpty() && ctx->uvMeshInstances.isEmpty()) { + XA_PRINT_WARNING("PackCharts: No meshes. Call AddMesh or AddUvMesh first.\n"); + return; + } + if (ctx->uvMeshInstances.isEmpty()) { + if (!ctx->paramAtlas.chartsComputed()) { + XA_PRINT_WARNING("PackCharts: ComputeCharts must be called first.\n"); + return; + } + } else if (!ctx->uvMeshChartsComputed) { + XA_PRINT_WARNING("PackCharts: ComputeCharts must be called first.\n"); + return; + } + if (packOptions.texelsPerUnit < 0.0f) { + XA_PRINT_WARNING("PackCharts: PackOptions::texelsPerUnit is negative.\n"); + packOptions.texelsPerUnit = 0.0f; + } + // Cleanup atlas. + DestroyOutputMeshes(ctx); + if (atlas->utilization) { + XA_FREE(atlas->utilization); + atlas->utilization = nullptr; + } + if (atlas->image) { + XA_FREE(atlas->image); + atlas->image = nullptr; + } + atlas->meshCount = 0; + // Pack charts. + XA_PROFILE_START(packChartsAddCharts) + internal::pack::Atlas packAtlas; + if (!ctx->uvMeshInstances.isEmpty()) { + for (uint32_t i = 0; i < ctx->uvMeshInstances.size(); i++) + packAtlas.addUvMeshCharts(ctx->uvMeshInstances[i]); + } + else + packAtlas.addCharts(ctx->taskScheduler, &ctx->paramAtlas); + XA_PROFILE_END(packChartsAddCharts) + XA_PROFILE_START(packCharts) + if (!packAtlas.packCharts(packOptions, ctx->progressFunc, ctx->progressUserData)) + return; + XA_PROFILE_END(packCharts) + // Populate atlas object with pack results. + atlas->atlasCount = packAtlas.getNumAtlases(); + atlas->chartCount = packAtlas.getChartCount(); + atlas->width = packAtlas.getWidth(); + atlas->height = packAtlas.getHeight(); + atlas->texelsPerUnit = packAtlas.getTexelsPerUnit(); + if (atlas->atlasCount > 0) { + atlas->utilization = XA_ALLOC_ARRAY(internal::MemTag::Default, float, atlas->atlasCount); + for (uint32_t i = 0; i < atlas->atlasCount; i++) + atlas->utilization[i] = packAtlas.getUtilization(i); + } + if (packOptions.createImage) { + atlas->image = XA_ALLOC_ARRAY(internal::MemTag::Default, uint32_t, atlas->atlasCount * atlas->width * atlas->height); + for (uint32_t i = 0; i < atlas->atlasCount; i++) + packAtlas.getImages()[i]->copyTo(&atlas->image[atlas->width * atlas->height * i], atlas->width, atlas->height, packOptions.padding); + } + XA_PROFILE_PRINT_AND_RESET(" Total: ", packCharts) + XA_PROFILE_PRINT_AND_RESET(" Add charts (real): ", packChartsAddCharts) + XA_PROFILE_PRINT_AND_RESET(" Add charts (thread): ", packChartsAddChartsThread) + XA_PROFILE_PRINT_AND_RESET(" Restore texcoords: ", packChartsAddChartsRestoreTexcoords) + XA_PROFILE_PRINT_AND_RESET(" Rasterize: ", packChartsRasterize) + XA_PROFILE_PRINT_AND_RESET(" Dilate (padding): ", packChartsDilate) + XA_PROFILE_PRINT_AND_RESET(" Find location: ", packChartsFindLocation) + XA_PROFILE_PRINT_AND_RESET(" Blit: ", packChartsBlit) +#if XA_PROFILE_ALLOC + XA_PROFILE_PRINT_AND_RESET(" Alloc: ", alloc) +#endif + XA_PRINT_MEM_USAGE + XA_PRINT("Building output meshes\n"); + XA_PROFILE_START(buildOutputMeshes) + int progress = 0; + if (ctx->progressFunc) { + if (!ctx->progressFunc(ProgressCategory::BuildOutputMeshes, 0, ctx->progressUserData)) + return; + } + if (ctx->uvMeshInstances.isEmpty()) + atlas->meshCount = ctx->meshes.size(); + else + atlas->meshCount = ctx->uvMeshInstances.size(); + atlas->meshes = XA_ALLOC_ARRAY(internal::MemTag::Default, Mesh, atlas->meshCount); + memset(atlas->meshes, 0, sizeof(Mesh) * atlas->meshCount); + if (ctx->uvMeshInstances.isEmpty()) { + uint32_t chartIndex = 0; + for (uint32_t i = 0; i < atlas->meshCount; i++) { + Mesh &outputMesh = atlas->meshes[i]; + MeshPolygonMapping *meshPolygonMapping = ctx->meshPolygonMappings[i]; + // One polygon can have many triangles. Don't want to process the same polygon more than once when counting indices, building chart faces etc. + internal::BitArray polygonTouched; + if (meshPolygonMapping) { + polygonTouched.resize(meshPolygonMapping->faceVertexCount.size()); + polygonTouched.zeroOutMemory(); + } + // Count and alloc arrays. + const internal::InvalidMeshGeometry &invalid = ctx->paramAtlas.invalidMeshGeometry(i); + outputMesh.vertexCount += invalid.vertices().length; + outputMesh.indexCount += invalid.faces().length * 3; + for (uint32_t cg = 0; cg < ctx->paramAtlas.chartGroupCount(i); cg++) { + const internal::param::ChartGroup *chartGroup = ctx->paramAtlas.chartGroupAt(i, cg); + for (uint32_t c = 0; c < chartGroup->chartCount(); c++) { + const internal::param::Chart *chart = chartGroup->chartAt(c); + outputMesh.vertexCount += chart->originalVertexCount(); + const uint32_t faceCount = chart->unifiedMesh()->faceCount(); + if (meshPolygonMapping) { + // Map triangles back to polygons and count the polygon vertices. + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t polygon = meshPolygonMapping->triangleToPolygonMap[chart->mapFaceToSourceFace(f)]; + if (!polygonTouched.get(polygon)) { + polygonTouched.set(polygon); + outputMesh.indexCount += meshPolygonMapping->faceVertexCount[polygon]; + } + } + } else { + outputMesh.indexCount += faceCount * 3; + } + outputMesh.chartCount++; + } + } + outputMesh.vertexArray = XA_ALLOC_ARRAY(internal::MemTag::Default, Vertex, outputMesh.vertexCount); + outputMesh.indexArray = XA_ALLOC_ARRAY(internal::MemTag::Default, uint32_t, outputMesh.indexCount); + outputMesh.chartArray = XA_ALLOC_ARRAY(internal::MemTag::Default, Chart, outputMesh.chartCount); + XA_PRINT(" Mesh %u: %u vertices, %u triangles, %u charts\n", i, outputMesh.vertexCount, outputMesh.indexCount / 3, outputMesh.chartCount); + // Copy mesh data. + uint32_t firstVertex = 0; + { + const internal::InvalidMeshGeometry &mesh = ctx->paramAtlas.invalidMeshGeometry(i); + internal::ConstArrayView faces = mesh.faces(); + internal::ConstArrayView indices = mesh.indices(); + internal::ConstArrayView vertices = mesh.vertices(); + // Vertices. + for (uint32_t v = 0; v < vertices.length; v++) { + Vertex &vertex = outputMesh.vertexArray[v]; + vertex.atlasIndex = -1; + vertex.chartIndex = -1; + vertex.uv[0] = vertex.uv[1] = 0.0f; + vertex.xref = vertices[v]; + } + // Indices. + for (uint32_t f = 0; f < faces.length; f++) { + const uint32_t indexOffset = faces[f] * 3; + for (uint32_t j = 0; j < 3; j++) + outputMesh.indexArray[indexOffset + j] = indices[f * 3 + j]; + } + firstVertex = vertices.length; + } + uint32_t meshChartIndex = 0; + for (uint32_t cg = 0; cg < ctx->paramAtlas.chartGroupCount(i); cg++) { + const internal::param::ChartGroup *chartGroup = ctx->paramAtlas.chartGroupAt(i, cg); + for (uint32_t c = 0; c < chartGroup->chartCount(); c++) { + const internal::param::Chart *chart = chartGroup->chartAt(c); + const internal::Mesh *unifiedMesh = chart->unifiedMesh(); + const uint32_t faceCount = unifiedMesh->faceCount(); +#if XA_CHECK_PARAM_WINDING + uint32_t flippedCount = 0; + for (uint32_t f = 0; f < faceCount; f++) { + const float area = mesh->computeFaceParametricArea(f); + if (area < 0.0f) + flippedCount++; + } + const char *type = "LSCM"; + if (chart->type() == ChartType::Planar) + type = "planar"; + else if (chart->type() == ChartType::Ortho) + type = "ortho"; + else if (chart->type() == ChartType::Piecewise) + type = "piecewise"; + if (flippedCount > 0) { + if (flippedCount == faceCount) { + XA_PRINT_WARNING("chart %u (%s): all face flipped\n", chartIndex, type); + } else { + XA_PRINT_WARNING("chart %u (%s): %u / %u faces flipped\n", chartIndex, type, flippedCount, faceCount); + } + } +#endif + // Vertices. + for (uint32_t v = 0; v < chart->originalVertexCount(); v++) { + Vertex &vertex = outputMesh.vertexArray[firstVertex + v]; + vertex.atlasIndex = packAtlas.getChart(chartIndex)->atlasIndex; + XA_DEBUG_ASSERT(vertex.atlasIndex >= 0); + vertex.chartIndex = (int32_t)chartIndex; + const internal::Vector2 &uv = unifiedMesh->texcoord(chart->originalVertexToUnifiedVertex(v)); + vertex.uv[0] = internal::max(0.0f, uv.x); + vertex.uv[1] = internal::max(0.0f, uv.y); + vertex.xref = chart->mapChartVertexToSourceVertex(v); + } + // Indices. + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t indexOffset = chart->mapFaceToSourceFace(f) * 3; + for (uint32_t j = 0; j < 3; j++) { + uint32_t outIndex = indexOffset + j; + if (meshPolygonMapping) + outIndex = meshPolygonMapping->triangleToPolygonIndicesMap[outIndex]; + outputMesh.indexArray[outIndex] = firstVertex + chart->originalVertices()[f * 3 + j]; + } + } + // Charts. + Chart *outputChart = &outputMesh.chartArray[meshChartIndex]; + const int32_t atlasIndex = packAtlas.getChart(chartIndex)->atlasIndex; + XA_DEBUG_ASSERT(atlasIndex >= 0); + outputChart->atlasIndex = (uint32_t)atlasIndex; + outputChart->type = chart->isInvalid() ? ChartType::Invalid : chart->type(); + if (meshPolygonMapping) { + // Count polygons. + polygonTouched.zeroOutMemory(); + outputChart->faceCount = 0; + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t polygon = meshPolygonMapping->triangleToPolygonMap[chart->mapFaceToSourceFace(f)]; + if (!polygonTouched.get(polygon)) { + polygonTouched.set(polygon); + outputChart->faceCount++; + } + } + // Write polygons. + outputChart->faceArray = XA_ALLOC_ARRAY(internal::MemTag::Default, uint32_t, outputChart->faceCount); + polygonTouched.zeroOutMemory(); + uint32_t of = 0; + for (uint32_t f = 0; f < faceCount; f++) { + const uint32_t polygon = meshPolygonMapping->triangleToPolygonMap[chart->mapFaceToSourceFace(f)]; + if (!polygonTouched.get(polygon)) { + polygonTouched.set(polygon); + outputChart->faceArray[of++] = polygon; + } + } + } else { + outputChart->faceCount = faceCount; + outputChart->faceArray = XA_ALLOC_ARRAY(internal::MemTag::Default, uint32_t, outputChart->faceCount); + for (uint32_t f = 0; f < outputChart->faceCount; f++) + outputChart->faceArray[f] = chart->mapFaceToSourceFace(f); + } + outputChart->material = 0; + meshChartIndex++; + chartIndex++; + firstVertex += chart->originalVertexCount(); + } + } + XA_DEBUG_ASSERT(outputMesh.vertexCount == firstVertex); + XA_DEBUG_ASSERT(outputMesh.chartCount == meshChartIndex); + if (ctx->progressFunc) { + const int newProgress = int((i + 1) / (float)atlas->meshCount * 100.0f); + if (newProgress != progress) { + progress = newProgress; + if (!ctx->progressFunc(ProgressCategory::BuildOutputMeshes, progress, ctx->progressUserData)) + return; + } + } + } + } else { + uint32_t chartIndex = 0; + for (uint32_t m = 0; m < ctx->uvMeshInstances.size(); m++) { + Mesh &outputMesh = atlas->meshes[m]; + const internal::UvMeshInstance *mesh = ctx->uvMeshInstances[m]; + // Alloc arrays. + outputMesh.vertexCount = mesh->texcoords.size(); + outputMesh.indexCount = mesh->mesh->indices.size(); + outputMesh.chartCount = mesh->mesh->charts.size(); + outputMesh.vertexArray = XA_ALLOC_ARRAY(internal::MemTag::Default, Vertex, outputMesh.vertexCount); + outputMesh.indexArray = XA_ALLOC_ARRAY(internal::MemTag::Default, uint32_t, outputMesh.indexCount); + outputMesh.chartArray = XA_ALLOC_ARRAY(internal::MemTag::Default, Chart, outputMesh.chartCount); + XA_PRINT(" UV mesh %u: %u vertices, %u triangles, %u charts\n", m, outputMesh.vertexCount, outputMesh.indexCount / 3, outputMesh.chartCount); + // Copy mesh data. + // Vertices. + for (uint32_t v = 0; v < mesh->texcoords.size(); v++) { + Vertex &vertex = outputMesh.vertexArray[v]; + vertex.uv[0] = mesh->texcoords[v].x; + vertex.uv[1] = mesh->texcoords[v].y; + vertex.xref = v; + const uint32_t meshChartIndex = mesh->mesh->vertexToChartMap[v]; + if (meshChartIndex == UINT32_MAX) { + // Vertex doesn't exist in any chart. + vertex.atlasIndex = -1; + vertex.chartIndex = -1; + } else { + const internal::pack::Chart *chart = packAtlas.getChart(chartIndex + meshChartIndex); + vertex.atlasIndex = chart->atlasIndex; + vertex.chartIndex = (int32_t)chartIndex + meshChartIndex; + } + } + // Indices. + memcpy(outputMesh.indexArray, mesh->mesh->indices.data(), mesh->mesh->indices.size() * sizeof(uint32_t)); + // Charts. + for (uint32_t c = 0; c < mesh->mesh->charts.size(); c++) { + Chart *outputChart = &outputMesh.chartArray[c]; + const internal::pack::Chart *chart = packAtlas.getChart(chartIndex); + XA_DEBUG_ASSERT(chart->atlasIndex >= 0); + outputChart->atlasIndex = (uint32_t)chart->atlasIndex; + outputChart->faceCount = chart->faces.size(); + outputChart->faceArray = XA_ALLOC_ARRAY(internal::MemTag::Default, uint32_t, outputChart->faceCount); + outputChart->material = chart->material; + for (uint32_t f = 0; f < outputChart->faceCount; f++) + outputChart->faceArray[f] = chart->faces[f]; + chartIndex++; + } + if (ctx->progressFunc) { + const int newProgress = int((m + 1) / (float)atlas->meshCount * 100.0f); + if (newProgress != progress) { + progress = newProgress; + if (!ctx->progressFunc(ProgressCategory::BuildOutputMeshes, progress, ctx->progressUserData)) + return; + } + } + } + } + if (ctx->progressFunc && progress != 100) + ctx->progressFunc(ProgressCategory::BuildOutputMeshes, 100, ctx->progressUserData); + XA_PROFILE_END(buildOutputMeshes) + XA_PROFILE_PRINT_AND_RESET(" Total: ", buildOutputMeshes) +#if XA_PROFILE_ALLOC + XA_PROFILE_PRINT_AND_RESET(" Alloc: ", alloc) +#endif + XA_PRINT_MEM_USAGE +} + +void Generate(Atlas *atlas, ChartOptions chartOptions, PackOptions packOptions) +{ + if (!atlas) { + XA_PRINT_WARNING("Generate: atlas is null.\n"); + return; + } + Context *ctx = (Context *)atlas; + if (ctx->meshes.isEmpty() && ctx->uvMeshInstances.isEmpty()) { + XA_PRINT_WARNING("Generate: No meshes. Call AddMesh or AddUvMesh first.\n"); + return; + } + ComputeCharts(atlas, chartOptions); + PackCharts(atlas, packOptions); +} + +void SetProgressCallback(Atlas *atlas, ProgressFunc progressFunc, void *progressUserData) +{ + if (!atlas) { + XA_PRINT_WARNING("SetProgressCallback: atlas is null.\n"); + return; + } + Context *ctx = (Context *)atlas; + ctx->progressFunc = progressFunc; + ctx->progressUserData = progressUserData; +} + +void SetAlloc(ReallocFunc reallocFunc, FreeFunc freeFunc) +{ + internal::s_realloc = reallocFunc; + internal::s_free = freeFunc; +} + +void SetPrint(PrintFunc print, bool verbose) +{ + internal::s_print = print; + internal::s_printVerbose = verbose; +} + +const char *StringForEnum(AddMeshError error) +{ + if (error == AddMeshError::Error) + return "Unspecified error"; + if (error == AddMeshError::IndexOutOfRange) + return "Index out of range"; + if (error == AddMeshError::InvalidFaceVertexCount) + return "Invalid face vertex count"; + if (error == AddMeshError::InvalidIndexCount) + return "Invalid index count"; + return "Success"; +} + +const char *StringForEnum(ProgressCategory category) +{ + if (category == ProgressCategory::AddMesh) + return "Adding mesh(es)"; + if (category == ProgressCategory::ComputeCharts) + return "Computing charts"; + if (category == ProgressCategory::PackCharts) + return "Packing charts"; + if (category == ProgressCategory::BuildOutputMeshes) + return "Building output meshes"; + return ""; +} + +} // namespace xatlas + +#if XATLAS_C_API +static_assert(sizeof(xatlas::Chart) == sizeof(xatlasChart), "xatlasChart size mismatch"); +static_assert(sizeof(xatlas::Vertex) == sizeof(xatlasVertex), "xatlasVertex size mismatch"); +static_assert(sizeof(xatlas::Mesh) == sizeof(xatlasMesh), "xatlasMesh size mismatch"); +static_assert(sizeof(xatlas::Atlas) == sizeof(xatlasAtlas), "xatlasAtlas size mismatch"); +static_assert(sizeof(xatlas::MeshDecl) == sizeof(xatlasMeshDecl), "xatlasMeshDecl size mismatch"); +static_assert(sizeof(xatlas::UvMeshDecl) == sizeof(xatlasUvMeshDecl), "xatlasUvMeshDecl size mismatch"); +static_assert(sizeof(xatlas::ChartOptions) == sizeof(xatlasChartOptions), "xatlasChartOptions size mismatch"); +static_assert(sizeof(xatlas::PackOptions) == sizeof(xatlasPackOptions), "xatlasPackOptions size mismatch"); + +#ifdef __cplusplus +extern "C" { +#endif + +xatlasAtlas *xatlasCreate() +{ + return (xatlasAtlas *)xatlas::Create(); +} + +void xatlasDestroy(xatlasAtlas *atlas) +{ + xatlas::Destroy((xatlas::Atlas *)atlas); +} + +xatlasAddMeshError xatlasAddMesh(xatlasAtlas *atlas, const xatlasMeshDecl *meshDecl, uint32_t meshCountHint) +{ + return (xatlasAddMeshError)xatlas::AddMesh((xatlas::Atlas *)atlas, *(const xatlas::MeshDecl *)meshDecl, meshCountHint); +} + +void xatlasAddMeshJoin(xatlasAtlas *atlas) +{ + xatlas::AddMeshJoin((xatlas::Atlas *)atlas); +} + +xatlasAddMeshError xatlasAddUvMesh(xatlasAtlas *atlas, const xatlasUvMeshDecl *decl) +{ + return (xatlasAddMeshError)xatlas::AddUvMesh((xatlas::Atlas *)atlas, *(const xatlas::UvMeshDecl *)decl); +} + +void xatlasComputeCharts(xatlasAtlas *atlas, const xatlasChartOptions *chartOptions) +{ + xatlas::ComputeCharts((xatlas::Atlas *)atlas, chartOptions ? *(xatlas::ChartOptions *)chartOptions : xatlas::ChartOptions()); +} + +void xatlasPackCharts(xatlasAtlas *atlas, const xatlasPackOptions *packOptions) +{ + xatlas::PackCharts((xatlas::Atlas *)atlas, packOptions ? *(xatlas::PackOptions *)packOptions : xatlas::PackOptions()); +} + +void xatlasGenerate(xatlasAtlas *atlas, const xatlasChartOptions *chartOptions, const xatlasPackOptions *packOptions) +{ + xatlas::Generate((xatlas::Atlas *)atlas, chartOptions ? *(xatlas::ChartOptions *)chartOptions : xatlas::ChartOptions(), packOptions ? *(xatlas::PackOptions *)packOptions : xatlas::PackOptions()); +} + +void xatlasSetProgressCallback(xatlasAtlas *atlas, xatlasProgressFunc progressFunc, void *progressUserData) +{ + xatlas::ProgressFunc pf; + *(void **)&pf = (void *)progressFunc; + xatlas::SetProgressCallback((xatlas::Atlas *)atlas, pf, progressUserData); +} + +void xatlasSetAlloc(xatlasReallocFunc reallocFunc, xatlasFreeFunc freeFunc) +{ + xatlas::SetAlloc((xatlas::ReallocFunc)reallocFunc, (xatlas::FreeFunc)freeFunc); +} + +void xatlasSetPrint(xatlasPrintFunc print, bool verbose) +{ + xatlas::SetPrint((xatlas::PrintFunc)print, verbose); +} + +const char *xatlasAddMeshErrorString(xatlasAddMeshError error) +{ + return xatlas::StringForEnum((xatlas::AddMeshError)error); +} + +const char *xatlasProgressCategoryString(xatlasProgressCategory category) +{ + return xatlas::StringForEnum((xatlas::ProgressCategory)category); +} + +void xatlasMeshDeclInit(xatlasMeshDecl *meshDecl) +{ + xatlas::MeshDecl init; + memcpy(meshDecl, &init, sizeof(init)); +} + +void xatlasUvMeshDeclInit(xatlasUvMeshDecl *uvMeshDecl) +{ + xatlas::UvMeshDecl init; + memcpy(uvMeshDecl, &init, sizeof(init)); +} + +void xatlasChartOptionsInit(xatlasChartOptions *chartOptions) +{ + xatlas::ChartOptions init; + memcpy(chartOptions, &init, sizeof(init)); +} + +void xatlasPackOptionsInit(xatlasPackOptions *packOptions) +{ + xatlas::PackOptions init; + memcpy(packOptions, &init, sizeof(init)); +} + +#ifdef __cplusplus +} // extern "C" +#endif +#endif // XATLAS_C_API diff --git a/Native~/third_party/xatlas/xatlas.h b/Native~/third_party/xatlas/xatlas.h new file mode 100644 index 00000000..d66a96db --- /dev/null +++ b/Native~/third_party/xatlas/xatlas.h @@ -0,0 +1,269 @@ +/* +MIT License + +Copyright (c) 2018-2020 Jonathan Young + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +/* +thekla_atlas +MIT License +https://github.com/Thekla/thekla_atlas +Copyright (c) 2013 Thekla, Inc +Copyright NVIDIA Corporation 2006 -- Ignacio Castano +*/ +#pragma once +#ifndef XATLAS_H +#define XATLAS_H +#include +#include + +namespace xatlas { + +enum class ChartType +{ + Planar, + Ortho, + LSCM, + Piecewise, + Invalid +}; + +// A group of connected faces, belonging to a single atlas. +struct Chart +{ + uint32_t *faceArray; + uint32_t atlasIndex; // Sub-atlas index. + uint32_t faceCount; + ChartType type; + uint32_t material; +}; + +// Output vertex. +struct Vertex +{ + int32_t atlasIndex; // Sub-atlas index. -1 if the vertex doesn't exist in any atlas. + int32_t chartIndex; // -1 if the vertex doesn't exist in any chart. + float uv[2]; // Not normalized - values are in Atlas width and height range. + uint32_t xref; // Index of input vertex from which this output vertex originated. +}; + +// Output mesh. +struct Mesh +{ + Chart *chartArray; + uint32_t *indexArray; + Vertex *vertexArray; + uint32_t chartCount; + uint32_t indexCount; + uint32_t vertexCount; +}; + +static const uint32_t kImageChartIndexMask = 0x1FFFFFFF; +static const uint32_t kImageHasChartIndexBit = 0x80000000; +static const uint32_t kImageIsBilinearBit = 0x40000000; +static const uint32_t kImageIsPaddingBit = 0x20000000; + +// Empty on creation. Populated after charts are packed. +struct Atlas +{ + uint32_t *image; + Mesh *meshes; // The output meshes, corresponding to each AddMesh call. + float *utilization; // Normalized atlas texel utilization array. E.g. a value of 0.8 means 20% empty space. atlasCount in length. + uint32_t width; // Atlas width in texels. + uint32_t height; // Atlas height in texels. + uint32_t atlasCount; // Number of sub-atlases. Equal to 0 unless PackOptions resolution is changed from default (0). + uint32_t chartCount; // Total number of charts in all meshes. + uint32_t meshCount; // Number of output meshes. Equal to the number of times AddMesh was called. + float texelsPerUnit; // Equal to PackOptions texelsPerUnit if texelsPerUnit > 0, otherwise an estimated value to match PackOptions resolution. +}; + +// Create an empty atlas. +Atlas *Create(); + +void Destroy(Atlas *atlas); + +enum class IndexFormat +{ + UInt16, + UInt32 +}; + +// Input mesh declaration. +struct MeshDecl +{ + const void *vertexPositionData = nullptr; + const void *vertexNormalData = nullptr; // optional + const void *vertexUvData = nullptr; // optional. The input UVs are provided as a hint to the chart generator. + const void *indexData = nullptr; // optional + + // Optional. Must be faceCount in length. + // Don't atlas faces set to true. Ignored faces still exist in the output meshes, Vertex uv is set to (0, 0) and Vertex atlasIndex to -1. + const bool *faceIgnoreData = nullptr; + + // Optional. Must be faceCount in length. + // Only faces with the same material will be assigned to the same chart. + const uint32_t *faceMaterialData = nullptr; + + // Optional. Must be faceCount in length. + // Polygon / n-gon support. Faces are assumed to be triangles if this is null. + const uint8_t *faceVertexCount = nullptr; + + uint32_t vertexCount = 0; + uint32_t vertexPositionStride = 0; + uint32_t vertexNormalStride = 0; // optional + uint32_t vertexUvStride = 0; // optional + uint32_t indexCount = 0; + int32_t indexOffset = 0; // optional. Add this offset to all indices. + uint32_t faceCount = 0; // Optional if faceVertexCount is null. Otherwise assumed to be indexCount / 3. + IndexFormat indexFormat = IndexFormat::UInt16; + + // Vertex positions within epsilon distance of each other are considered colocal. + float epsilon = 1.192092896e-07F; +}; + +enum class AddMeshError +{ + Success, // No error. + Error, // Unspecified error. + IndexOutOfRange, // An index is >= MeshDecl vertexCount. + InvalidFaceVertexCount, // Must be >= 3. + InvalidIndexCount // Not evenly divisible by 3 - expecting triangles. +}; + +// Add a mesh to the atlas. MeshDecl data is copied, so it can be freed after AddMesh returns. +AddMeshError AddMesh(Atlas *atlas, const MeshDecl &meshDecl, uint32_t meshCountHint = 0); + +// Wait for AddMesh async processing to finish. ComputeCharts / Generate call this internally. +void AddMeshJoin(Atlas *atlas); + +struct UvMeshDecl +{ + const void *vertexUvData = nullptr; + const void *indexData = nullptr; // optional + const uint32_t *faceMaterialData = nullptr; // Optional. Overlapping UVs should be assigned a different material. Must be indexCount / 3 in length. + uint32_t vertexCount = 0; + uint32_t vertexStride = 0; + uint32_t indexCount = 0; + int32_t indexOffset = 0; // optional. Add this offset to all indices. + IndexFormat indexFormat = IndexFormat::UInt16; +}; + +AddMeshError AddUvMesh(Atlas *atlas, const UvMeshDecl &decl); + +// Custom parameterization function. texcoords initial values are an orthogonal parameterization. +typedef void (*ParameterizeFunc)(const float *positions, float *texcoords, uint32_t vertexCount, const uint32_t *indices, uint32_t indexCount); + +struct ChartOptions +{ + ParameterizeFunc paramFunc = nullptr; + + float maxChartArea = 0.0f; // Don't grow charts to be larger than this. 0 means no limit. + float maxBoundaryLength = 0.0f; // Don't grow charts to have a longer boundary than this. 0 means no limit. + + // Weights determine chart growth. Higher weights mean higher cost for that metric. + float normalDeviationWeight = 2.0f; // Angle between face and average chart normal. + float roundnessWeight = 0.01f; + float straightnessWeight = 6.0f; + float normalSeamWeight = 4.0f; // If > 1000, normal seams are fully respected. + float textureSeamWeight = 0.5f; + + float maxCost = 2.0f; // If total of all metrics * weights > maxCost, don't grow chart. Lower values result in more charts. + uint32_t maxIterations = 1; // Number of iterations of the chart growing and seeding phases. Higher values result in better charts. + + bool useInputMeshUvs = false; // Use MeshDecl::vertexUvData for charts. + bool fixWinding = false; // Enforce consistent texture coordinate winding. +}; + +// Call after all AddMesh calls. Can be called multiple times to recompute charts with different options. +void ComputeCharts(Atlas *atlas, ChartOptions options = ChartOptions()); + +struct PackOptions +{ + // Charts larger than this will be scaled down. 0 means no limit. + uint32_t maxChartSize = 0; + + // Number of pixels to pad charts with. + uint32_t padding = 0; + + // Unit to texel scale. e.g. a 1x1 quad with texelsPerUnit of 32 will take up approximately 32x32 texels in the atlas. + // If 0, an estimated value will be calculated to approximately match the given resolution. + // If resolution is also 0, the estimated value will approximately match a 1024x1024 atlas. + float texelsPerUnit = 0.0f; + + // If 0, generate a single atlas with texelsPerUnit determining the final resolution. + // If not 0, and texelsPerUnit is not 0, generate one or more atlases with that exact resolution. + // If not 0, and texelsPerUnit is 0, texelsPerUnit is estimated to approximately match the resolution. + uint32_t resolution = 0; + + // Leave space around charts for texels that would be sampled by bilinear filtering. + bool bilinear = true; + + // Align charts to 4x4 blocks. Also improves packing speed, since there are fewer possible chart locations to consider. + bool blockAlign = false; + + // Slower, but gives the best result. If false, use random chart placement. + bool bruteForce = false; + + // Create Atlas::image + bool createImage = false; + + // Rotate charts to the axis of their convex hull. + bool rotateChartsToAxis = true; + + // Rotate charts to improve packing. + bool rotateCharts = true; +}; + +// Call after ComputeCharts. Can be called multiple times to re-pack charts with different options. +void PackCharts(Atlas *atlas, PackOptions packOptions = PackOptions()); + +// Equivalent to calling ComputeCharts and PackCharts in sequence. Can be called multiple times to regenerate with different options. +void Generate(Atlas *atlas, ChartOptions chartOptions = ChartOptions(), PackOptions packOptions = PackOptions()); + +// Progress tracking. +enum class ProgressCategory +{ + AddMesh, + ComputeCharts, + PackCharts, + BuildOutputMeshes +}; + +// May be called from any thread. Return false to cancel. +typedef bool (*ProgressFunc)(ProgressCategory category, int progress, void *userData); + +void SetProgressCallback(Atlas *atlas, ProgressFunc progressFunc = nullptr, void *progressUserData = nullptr); + +// Custom memory allocation. +typedef void *(*ReallocFunc)(void *, size_t); +typedef void (*FreeFunc)(void *); +void SetAlloc(ReallocFunc reallocFunc, FreeFunc freeFunc = nullptr); + +// Custom print function. +typedef int (*PrintFunc)(const char *, ...); +void SetPrint(PrintFunc print, bool verbose); + +// Helper functions for error messages. +const char *StringForEnum(AddMeshError error); +const char *StringForEnum(ProgressCategory category); + +} // namespace xatlas + +#endif // XATLAS_H diff --git a/README.md b/README.md index eeaeeece..8130c0d5 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ cmake -S Native -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release ``` -Requirements: CMake 3.20+, C++17 compiler. Dependencies (xatlas, meshoptimizer) are fetched automatically via CMake FetchContent. V-HACD is included as a header-only file in `Native/third_party/`. +Requirements: CMake 3.20+, C++17 compiler. xatlas and V-HACD are vendored in `Native~/third_party/`; meshoptimizer is fetched automatically via CMake FetchContent. GitHub Actions CI automatically builds for Windows, Linux, and macOS on changes to `Native/`. From ed32493ade502a06c9a6e2dc37f35a4a332f8c8d Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:36:52 +0200 Subject: [PATCH 07/76] fix(repack): prevent atlas oversample size overflow (#129) resolution * internalOversample was computed in uint and the pack cost in long, so both could wrap and silently bypass the pack-cost safety budget before reaching native xatlas. Resolve dimensions through ulong and reject out-of-range values; saturate ComputePackCost at long.MaxValue. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 10 +++++ Editor/XatlasRepack.cs | 50 ++++++++++++++++++--- Tests/Editor/XatlasRepackGroupMergeTests.cs | 31 +++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 54ed2b86..1c1de69d 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -3,6 +3,16 @@ > **Обновлять этот документ при каждом эксперименте с transfer pipeline.** > Последнее обновление: v0.15.39 (2026-04-07) +## Эксперимент 2026-08-06 — безопасные размеры oversampled atlas + +- **Проблема:** умножение `resolution × internalOversample` в `uint` и расчёт + стоимости pack в `long` могли переполниться до вызова native xatlas. +- **Изменение:** размеры вычисляются через `ulong` и отклоняются при выходе за + диапазон `uint`; стоимость pack насыщается до `long.MaxValue`, чтобы лимит + всегда отклонял чрезмерный atlas. +- **Ожидание/проверка:** штатные разрешения не меняются; переполняющиеся значения + завершают repack с ошибкой до native pack. + ## Правила экспериментов 1. Один PR = одно изменение. Не наслаивать фиксы. diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index dacba5dc..701dfd21 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -692,8 +692,34 @@ static void LogStageBRisk( /// static long ComputePackCost(int shellCount, uint internalRes) { + long shells = Math.Max(0, shellCount); long res = internalRes; - return (long)Math.Max(0, shellCount) * res * res; + if (res != 0 && shells > long.MaxValue / res) + return long.MaxValue; + + long shellPixels = shells * res; + if (res != 0 && shellPixels > long.MaxValue / res) + return long.MaxValue; + + return shellPixels * res; + } + + static bool TryResolveInternalPackDimensions( + RepackOptions opts, out int oversample, out uint resolution, out uint padding) + { + oversample = opts.internalOversample > 0 ? opts.internalOversample : 1; + ulong resolvedResolution = (ulong)opts.resolution * (uint)oversample; + ulong resolvedPadding = (ulong)opts.padding * (uint)oversample; + if (resolvedResolution > uint.MaxValue || resolvedPadding > uint.MaxValue) + { + resolution = 0; + padding = 0; + return false; + } + + resolution = (uint)resolvedResolution; + padding = (uint)resolvedPadding; + return true; } static int ResolvePackBruteForce( @@ -1113,9 +1139,13 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) // resolution makes every chart's extent oversample× larger, so // ceil rounding becomes fractional. Padding scales by the same // factor to keep the gap fraction in UV space constant. - int oversample = opts.internalOversample > 0 ? opts.internalOversample : 1; - uint internalRes = opts.resolution * (uint)oversample; - uint internalPad = opts.padding * (uint)oversample; + if (!TryResolveInternalPackDimensions( + opts, out int oversample, out uint internalRes, out uint internalPad)) + { + result.error = "atlas resolution or padding is too large for the internal oversample"; + UvtLog.Warn(UvtLog.Category.Repack, $"[xatlas] {result.error} — refusing to start pack."); + return result; + } bool packed = RunPackCancelable( mesh.name, shells.Count, internalRes, oversample, @@ -1442,9 +1472,15 @@ static async Task RepackMultiCore(Mesh[] meshes, RepackOptions o // Pack all charts together into one atlas // See RepackSingle for oversample rationale (ceil-stretch fix) // and RunPackCancelable for cost-budget + cancel handling. - int oversampleM = opts.internalOversample > 0 ? opts.internalOversample : 1; - uint internalResM = opts.resolution * (uint)oversampleM; - uint internalPadM = opts.padding * (uint)oversampleM; + if (!TryResolveInternalPackDimensions( + opts, out int oversampleM, out uint internalResM, out uint internalPadM)) + { + const string error = "atlas resolution or padding is too large for the internal oversample"; + UvtLog.Warn(UvtLog.Category.Repack, $"[xatlas] {error} — refusing to start pack."); + for (int m = 0; m < meshCount; m++) + results[m].error = error; + return results; + } int totalShellsM = 0; for (int m = 0; m < meshCount; m++) diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs index d86c0c6f..d367facb 100644 --- a/Tests/Editor/XatlasRepackGroupMergeTests.cs +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -159,6 +159,37 @@ public void PackPreflight_DisablesBruteForce_WhenInternalOversampleIsAboveOne() "Oversampled packs should use the xatlas heuristic packer even when the UI brute-force toggle is enabled."); StringAssert.Contains("oversample", (string)args[4]); } + + [Test] + public void PackPreflight_RejectsOverflowingOversampledDimensions() + { + var method = typeof(XatlasRepack).GetMethod( + "TryResolveInternalPackDimensions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "XatlasRepack should validate oversampled dimensions before native packing"); + + var opts = RepackOptions.Default; + opts.resolution = (uint)int.MaxValue; + opts.internalOversample = 4; + object[] args = { opts, 0, (uint)0, (uint)0 }; + + Assert.IsFalse((bool)method.Invoke(null, args)); + Assert.AreEqual(0u, (uint)args[2]); + Assert.AreEqual(0u, (uint)args[3]); + } + + [Test] + public void PackCost_SaturatesInsteadOfOverflowing() + { + var method = typeof(XatlasRepack).GetMethod( + "ComputePackCost", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "XatlasRepack should expose pack cost calculation as a testable helper"); + + long cost = (long)method.Invoke(null, new object[] { 1, uint.MaxValue }); + + Assert.AreEqual(long.MaxValue, cost); + } } public class GroupedShellTransferTests From a6667c5e1773c1ff8301062b91e384704ba4b06b Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:43:21 +0200 Subject: [PATCH 08/76] fix(benchmark): escape CSV formula prefixes in sweep summaries (#157) BenchmarkSweep.WriteSummaryCsv wrote user-controlled paths unescaped, so a cell could start with =, +, - or @ and be evaluated as a formula on open. Prefix such values with an apostrophe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/BenchmarkSweep.cs | 5 ++++ Tests/Editor/BenchmarkSweepTests.cs | 30 ++++++++++++++++++++++++ Tests/Editor/BenchmarkSweepTests.cs.meta | 2 ++ 3 files changed, 37 insertions(+) create mode 100644 Tests/Editor/BenchmarkSweepTests.cs create mode 100644 Tests/Editor/BenchmarkSweepTests.cs.meta diff --git a/Editor/BenchmarkSweep.cs b/Editor/BenchmarkSweep.cs index 7ea6abc8..4bdb3bf8 100644 --- a/Editor/BenchmarkSweep.cs +++ b/Editor/BenchmarkSweep.cs @@ -887,6 +887,11 @@ internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) static string Csv(string s) { if (string.IsNullOrEmpty(s)) return ""; + // Spreadsheet applications can execute cells that begin with one + // of these characters as formulas. Prefix such values with an + // apostrophe so recovered, user-controlled filenames stay text. + if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@') + s = "'" + s; bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; if (!needQuote) return s; return "\"" + s.Replace("\"", "\"\"") + "\""; diff --git a/Tests/Editor/BenchmarkSweepTests.cs b/Tests/Editor/BenchmarkSweepTests.cs new file mode 100644 index 00000000..b6da7613 --- /dev/null +++ b/Tests/Editor/BenchmarkSweepTests.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using NUnit.Framework; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class BenchmarkSweepTests + { + static string EscapeCsv(string value) + { + var type = typeof(UvShellExtractor).Assembly.GetType("SashaRX.UnityMeshLab.BenchmarkSweep"); + var method = type.GetMethod("Csv", BindingFlags.NonPublic | BindingFlags.Static); + return (string)method.Invoke(null, new object[] { value }); + } + + [TestCase("=SUM(1,1)", "\"'=SUM(1,1)\"")] + [TestCase("+cmd", "'+cmd")] + [TestCase("-2+3", "'-2+3")] + [TestCase("@SUM(1,1)", "\"'@SUM(1,1)\"")] + public void Csv_FormulaLeadingValue_PrefixesApostrophe(string value, string expected) + { + Assert.AreEqual(expected, EscapeCsv(value)); + } + + [Test] + public void Csv_OrdinaryFilename_RemainsUnchanged() + { + Assert.AreEqual("report.csv", EscapeCsv("report.csv")); + } + } +} diff --git a/Tests/Editor/BenchmarkSweepTests.cs.meta b/Tests/Editor/BenchmarkSweepTests.cs.meta new file mode 100644 index 00000000..3fc69a4e --- /dev/null +++ b/Tests/Editor/BenchmarkSweepTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 721e47bb66814ba8a4adb097ada51861 From 0e50071f05c0e0988ccf9b7ed242a816b667dd84 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:43:51 +0200 Subject: [PATCH 09/76] fix(fbx): escape CSV formula prefixes in metrics export (#159) FbxMetricsExporter.WriteCsv wrote modelName/lodGroupName/rendererName unescaped, so a cell could start with =, +, - or @ and be evaluated as a formula on open. Prefix such values with an apostrophe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/FbxMetricsExporter.cs | 4 +++ Tests/Editor/FbxMetricsExporterTests.cs | 29 ++++++++++++++++++++ Tests/Editor/FbxMetricsExporterTests.cs.meta | 2 ++ 3 files changed, 35 insertions(+) create mode 100644 Tests/Editor/FbxMetricsExporterTests.cs create mode 100644 Tests/Editor/FbxMetricsExporterTests.cs.meta diff --git a/Editor/FbxMetricsExporter.cs b/Editor/FbxMetricsExporter.cs index 93dbe518..3bcd9595 100644 --- a/Editor/FbxMetricsExporter.cs +++ b/Editor/FbxMetricsExporter.cs @@ -392,6 +392,10 @@ static void WriteCsv(string dir, string stamp, List rows) static string Csv(string s) { if (string.IsNullOrEmpty(s)) return ""; + // Spreadsheet applications may interpret these prefixes as formulas. + // A leading apostrophe forces the exported cell to remain plain text. + if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@') + s = "'" + s; bool q = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; return q ? "\"" + s.Replace("\"", "\"\"") + "\"" : s; } diff --git a/Tests/Editor/FbxMetricsExporterTests.cs b/Tests/Editor/FbxMetricsExporterTests.cs new file mode 100644 index 00000000..9bd371ed --- /dev/null +++ b/Tests/Editor/FbxMetricsExporterTests.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using NUnit.Framework; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class FbxMetricsExporterTests + { + static string Csv(string value) + { + var method = typeof(FbxMetricsExporter).GetMethod( + "Csv", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method); + return (string)method.Invoke(null, new object[] { value }); + } + + [TestCase("=1+1", "'=1+1")] + [TestCase("+1+1", "'+1+1")] + [TestCase("-1+1", "'-1+1")] + [TestCase("@SUM(A1:A2)", "'@SUM(A1:A2)")] + [TestCase("safe", "safe")] + [TestCase("safe,value", "\"safe,value\"")] + [TestCase("=SUM(1,2)", "\"'=SUM(1,2)\"")] + public void Csv_NeutralizesFormulaPrefixesAndPreservesEscaping( + string value, string expected) + { + Assert.AreEqual(expected, Csv(value)); + } + } +} diff --git a/Tests/Editor/FbxMetricsExporterTests.cs.meta b/Tests/Editor/FbxMetricsExporterTests.cs.meta new file mode 100644 index 00000000..4b3ff190 --- /dev/null +++ b/Tests/Editor/FbxMetricsExporterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a7d4e8f1c2b34962aab3c90d0df62a71 From e157498d10c6b81ce5d5ba7aaf804c12c7f2ea4a Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:44:31 +0200 Subject: [PATCH 10/76] fix(benchmark): escape CSV formula prefixes in benchmark records (#161) BenchmarkRecorder.Csv wrote user-controlled asset names unescaped. Prefix values leading with =, +, -, @, tab, CR or LF with an apostrophe so the exported cell stays plain text. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/BenchmarkRecorder.cs | 8 ++++ Tests/Editor/BenchmarkRecorderTests.cs | 43 +++++++++++++++++++++ Tests/Editor/BenchmarkRecorderTests.cs.meta | 11 ++++++ 3 files changed, 62 insertions(+) create mode 100644 Tests/Editor/BenchmarkRecorderTests.cs create mode 100644 Tests/Editor/BenchmarkRecorderTests.cs.meta diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index cca64afa..b3b1a7f0 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -497,6 +497,14 @@ static void AppendJsonString(StringBuilder sb, string s) static string Csv(string s) { if (string.IsNullOrEmpty(s)) return ""; + + // Spreadsheet applications can evaluate cells beginning with these + // characters as formulas. Prefix untrusted text with an apostrophe so + // imported asset names remain data when the report is opened. + if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@' || + s[0] == '\t' || s[0] == '\r' || s[0] == '\n') + s = "'" + s; + bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; if (!needQuote) return s; return "\"" + s.Replace("\"", "\"\"") + "\""; diff --git a/Tests/Editor/BenchmarkRecorderTests.cs b/Tests/Editor/BenchmarkRecorderTests.cs new file mode 100644 index 00000000..76dcb387 --- /dev/null +++ b/Tests/Editor/BenchmarkRecorderTests.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using NUnit.Framework; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class BenchmarkRecorderTests + { + static string Csv(string value) + { + var method = typeof(BenchmarkRecorder).GetMethod( + "Csv", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method); + return (string)method.Invoke(null, new object[] { value }); + } + + [TestCase("=1+1", "'=1+1")] + [TestCase("+SUM(A1:A2)", "'+SUM(A1:A2)")] + [TestCase("-1+2", "'-1+2")] + [TestCase("@SUM(A1:A2)", "'@SUM(A1:A2)")] + [TestCase("\t=1+1", "'\t=1+1")] + [TestCase("\r=1+1", "\"'\r=1+1\"")] + [TestCase("\n=1+1", "\"'\n=1+1\"")] + public void Csv_FormulaPrefix_NeutralizesCell(string value, string expected) + { + Assert.AreEqual(expected, Csv(value)); + } + + [Test] + public void Csv_FormulaWithDelimiter_NeutralizesAndQuotesCell() + { + Assert.AreEqual("\"'=HYPERLINK(\"\"https://example.invalid\"\",\"\"open\"\")\"", + Csv("=HYPERLINK(\"https://example.invalid\",\"open\")")); + } + + [TestCase("Mesh_LOD0")] + [TestCase("1-mesh")] + [TestCase("")] + public void Csv_SafeValue_RemainsUnchanged(string value) + { + Assert.AreEqual(value, Csv(value)); + } + } +} diff --git a/Tests/Editor/BenchmarkRecorderTests.cs.meta b/Tests/Editor/BenchmarkRecorderTests.cs.meta new file mode 100644 index 00000000..dd7200c0 --- /dev/null +++ b/Tests/Editor/BenchmarkRecorderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b6215fc4e7c46b28c99cb54a79fd294 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From d183260fd3caf58d0c1554bca1eb9ff7bc830149 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:43:48 +0200 Subject: [PATCH 11/76] fix(tools): escape model name in gallery nav href (#158) render_model interpolated the model name into the nav link href without escaping, unlike render_index which already escaped the same pattern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Tools~/build_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools~/build_gallery.py b/Tools~/build_gallery.py index 1b1583f1..bc286800 100644 --- a/Tools~/build_gallery.py +++ b/Tools~/build_gallery.py @@ -412,7 +412,7 @@ def render_model(out_dir, gallery_id, model, all_cells, res_axis, pad_axis, bdr_ pairs.add((r.get("rendererName"), int(r.get("lodIndex", -1)))) pairs = sorted(pairs) - nav = " | ".join(f'{html.escape(m)}' for m in MODELS_ORDER) + nav = " | ".join(f'{html.escape(m)}' for m in MODELS_ORDER) nav += f' | index' parts = [f'UV2 — {html.escape(model)}', From 8892f9a9a1e1f80331020c5782d8354aac45b5fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:32:29 +0000 Subject: [PATCH 12/76] refactor(editor): share one CsvUtil.Escape across the CSV writers (#157, #159, #161) Follow-up to the three CSV formula-injection fixes, which each grew a near-identical private Csv() helper with slightly different prefix sets. Move the logic into internal static CsvUtil.Escape (superset behaviour: =, +, -, @, tab, CR, LF) and make BenchmarkSweep, FbxMetricsExporter and BenchmarkRecorder delegate to it. Each type keeps its private Csv entry point, so the tests added by those PRs are unchanged and still pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/BenchmarkRecorder.cs | 18 +++--------------- Editor/BenchmarkSweep.cs | 15 +++------------ Editor/CsvUtil.cs | 34 ++++++++++++++++++++++++++++++++++ Editor/CsvUtil.cs.meta | 11 +++++++++++ Editor/FbxMetricsExporter.cs | 13 +++---------- 5 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 Editor/CsvUtil.cs create mode 100644 Editor/CsvUtil.cs.meta diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index b3b1a7f0..9df87235 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -494,21 +494,9 @@ static void AppendJsonString(StringBuilder sb, string s) } } - static string Csv(string s) - { - if (string.IsNullOrEmpty(s)) return ""; - - // Spreadsheet applications can evaluate cells beginning with these - // characters as formulas. Prefix untrusted text with an apostrophe so - // imported asset names remain data when the report is opened. - if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@' || - s[0] == '\t' || s[0] == '\r' || s[0] == '\n') - s = "'" + s; - - bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - if (!needQuote) return s; - return "\"" + s.Replace("\"", "\"\"") + "\""; - } + // Imported asset names are user-controlled, so escaping covers both + // RFC 4180 quoting and spreadsheet formula neutralisation. + static string Csv(string s) => CsvUtil.Escape(s); static string Sanitize(string s) { diff --git a/Editor/BenchmarkSweep.cs b/Editor/BenchmarkSweep.cs index 4bdb3bf8..27573b8e 100644 --- a/Editor/BenchmarkSweep.cs +++ b/Editor/BenchmarkSweep.cs @@ -884,18 +884,9 @@ internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) const double kRecoveryGapSeconds = 300.0; // ── Helpers ── - static string Csv(string s) - { - if (string.IsNullOrEmpty(s)) return ""; - // Spreadsheet applications can execute cells that begin with one - // of these characters as formulas. Prefix such values with an - // apostrophe so recovered, user-controlled filenames stay text. - if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@') - s = "'" + s; - bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - if (!needQuote) return s; - return "\"" + s.Replace("\"", "\"\"") + "\""; - } + // Recovered, user-controlled filenames land in these cells, so escaping + // covers both RFC 4180 quoting and spreadsheet formula neutralisation. + static string Csv(string s) => CsvUtil.Escape(s); /// /// Minimal HTML escape covering the four characters that can break diff --git a/Editor/CsvUtil.cs b/Editor/CsvUtil.cs new file mode 100644 index 00000000..9050b0b3 --- /dev/null +++ b/Editor/CsvUtil.cs @@ -0,0 +1,34 @@ +namespace SashaRX.UnityMeshLab +{ + /// + /// Shared CSV field escaping for the tool's report writers + /// (BenchmarkRecorder, BenchmarkSweep, FbxMetricsExporter). + /// + internal static class CsvUtil + { + /// + /// Escapes a single CSV field. + /// + /// Two separate concerns, applied in this order: + /// 1. Formula neutralisation — spreadsheet applications evaluate a cell + /// whose text begins with =, +, -, @ or a + /// leading tab/CR/LF as a formula. Our fields carry user-controlled + /// data (asset names, file paths), so such values get an apostrophe + /// prefix that forces them to stay text. + /// 2. RFC 4180 quoting — fields containing a comma, quote or newline are + /// wrapped in double quotes with embedded quotes doubled. + /// + internal static string Escape(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + + if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@' || + s[0] == '\t' || s[0] == '\r' || s[0] == '\n') + s = "'" + s; + + bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; + if (!needQuote) return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; + } + } +} diff --git a/Editor/CsvUtil.cs.meta b/Editor/CsvUtil.cs.meta new file mode 100644 index 00000000..3d35b79e --- /dev/null +++ b/Editor/CsvUtil.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4a5825036d0496b9f7cc621076af062 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/FbxMetricsExporter.cs b/Editor/FbxMetricsExporter.cs index 3bcd9595..02008840 100644 --- a/Editor/FbxMetricsExporter.cs +++ b/Editor/FbxMetricsExporter.cs @@ -389,16 +389,9 @@ static void WriteCsv(string dir, string stamp, List rows) File.WriteAllText(Path.Combine(dir, $"FbxMetrics_{stamp}.csv"), sb.ToString(), Encoding.UTF8); } - static string Csv(string s) - { - if (string.IsNullOrEmpty(s)) return ""; - // Spreadsheet applications may interpret these prefixes as formulas. - // A leading apostrophe forces the exported cell to remain plain text. - if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@') - s = "'" + s; - bool q = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - return q ? "\"" + s.Replace("\"", "\"\"") + "\"" : s; - } + // modelName / lodGroupName / rendererName are user-controlled, so + // escaping covers both RFC 4180 quoting and formula neutralisation. + static string Csv(string s) => CsvUtil.Escape(s); static string Sanitize(string s) { From 8fc137367fde212a6fcf563a9253d61c43a061f1 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:42:50 +0200 Subject: [PATCH 13/76] fix(collision): validate sidecar collision entries before mesh rebuild (#151) Bounds-check hull/vertex/triangle ranges and index encodings from project-controlled sidecars before allocating or building meshes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/CollisionMeshTool.cs | 93 ++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/Editor/Tools/CollisionMeshTool.cs b/Editor/Tools/CollisionMeshTool.cs index 7a879461..28b6a4f3 100644 --- a/Editor/Tools/CollisionMeshTool.cs +++ b/Editor/Tools/CollisionMeshTool.cs @@ -565,6 +565,12 @@ void SaveToSidecar() foreach (var entry in data.collisionEntries) { + if (!TryValidateCollisionEntry(entry, out var globalTriangleIndices, out string validationError)) + { + UvtLog.Warn($"[Collision] Ignoring invalid sidecar entry: {validationError}"); + continue; + } + bool isConvex = entry.mode == 1; var meshes = new List(); int hullCount = entry.positionOffsets.Length; @@ -583,9 +589,12 @@ void SaveToSidecar() int idxCount = triEnd - triStart; var tris = new int[idxCount]; Array.Copy(entry.allTriangles, triStart, tris, 0, idxCount); - // Rebase indices to local vertex offset - for (int i = 0; i < tris.Length; i++) - tris[i] -= posStart; + if (globalTriangleIndices[h]) + { + // Current sidecars store indices in the flattened vertex array. + for (int i = 0; i < tris.Length; i++) + tris[i] -= posStart; + } var mesh = new Mesh(); mesh.name = isConvex @@ -603,6 +612,84 @@ void SaveToSidecar() return result; } + // Bounds keep malformed project-controlled sidecars from causing large + // secondary allocations during export. They are deliberately far above + // the expected size of collision geometry. + const int MaxSidecarHullCount = 1024; + const int MaxSidecarVertexCount = 1_000_000; + const int MaxSidecarIndexCount = 3_000_000; + + static bool TryValidateCollisionEntry( + CollisionMeshEntry entry, + out bool[] globalTriangleIndices, + out string error) + { + globalTriangleIndices = null; + error = null; + + if (entry == null) + return Invalid("entry is null", out error); + if (entry.mode != 0 && entry.mode != 1) + return Invalid($"'{entry.meshGroupKey}' has unknown mode {entry.mode}", out error); + if (entry.allPositions == null || entry.positionOffsets == null || + entry.allTriangles == null || entry.triangleOffsets == null) + return Invalid($"'{entry.meshGroupKey}' has missing mesh arrays", out error); + + int hullCount = entry.positionOffsets.Length; + if (hullCount == 0 || hullCount > MaxSidecarHullCount) + return Invalid($"'{entry.meshGroupKey}' has invalid hull count {hullCount}", out error); + if (entry.triangleOffsets.Length != hullCount) + return Invalid($"'{entry.meshGroupKey}' has mismatched offset arrays", out error); + if (entry.positionOffsets[0] != 0 || entry.triangleOffsets[0] != 0) + return Invalid($"'{entry.meshGroupKey}' has non-zero initial offsets", out error); + if (entry.allPositions.Length > MaxSidecarVertexCount || + entry.allTriangles.Length > MaxSidecarIndexCount) + return Invalid($"'{entry.meshGroupKey}' exceeds collision mesh size limits", out error); + + globalTriangleIndices = new bool[hullCount]; + for (int h = 0; h < hullCount; h++) + { + int posStart = entry.positionOffsets[h]; + int posEnd = h + 1 < hullCount ? entry.positionOffsets[h + 1] : entry.allPositions.Length; + int triStart = entry.triangleOffsets[h]; + int triEnd = h + 1 < hullCount ? entry.triangleOffsets[h + 1] : entry.allTriangles.Length; + + if (posStart < 0 || posEnd <= posStart || posEnd > entry.allPositions.Length) + return Invalid($"'{entry.meshGroupKey}' has invalid vertex range for hull {h}", out error); + if (triStart < 0 || triEnd <= triStart || triEnd > entry.allTriangles.Length || + (triEnd - triStart) % 3 != 0) + return Invalid($"'{entry.meshGroupKey}' has invalid triangle range for hull {h}", out error); + + int vertexCount = posEnd - posStart; + bool canBeLocal = true; + bool canBeGlobal = true; + for (int i = triStart; i < triEnd; i++) + { + int index = entry.allTriangles[i]; + canBeLocal &= index >= 0 && index < vertexCount; + canBeGlobal &= index >= posStart && index < posEnd; + } + + if (!canBeLocal && !canBeGlobal) + return Invalid($"'{entry.meshGroupKey}' has out-of-range indices for hull {h}", out error); + + // Before global rebasing was added, multi-hull sidecars stored + // per-hull local indices. Accept both safe encodings so existing + // generated data remains exportable. + // Prefer the current global encoding when an ambiguous range is + // valid as both; legacy local data normally contains index zero. + globalTriangleIndices[h] = canBeGlobal; + } + + return true; + } + + static bool Invalid(string message, out string error) + { + error = message; + return false; + } + void RemoveFromScene() { if (ctx.LodGroup == null) return; From 3bfda6e1754752369a70e1bb8d802db764ef1ad7 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:53:35 +0200 Subject: [PATCH 14/76] fix(repack): sanitize sidecar-supplied xatlas settings and save path (#189) Clamp atlas resolution and padding before they reach xatlas, and reject sidecar save paths that escape the Assets folder. The resolution ceiling is 16384 so manually typed values are not silently reduced. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 39 ++++++++++++++---- Tests/Editor/ToolSettingsValidationTests.cs | 40 +++++++++++++++++++ .../ToolSettingsValidationTests.cs.meta | 2 + 3 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 Tests/Editor/ToolSettingsValidationTests.cs create mode 100644 Tests/Editor/ToolSettingsValidationTests.cs.meta diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 4e680229..f6906808 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -1997,7 +1997,7 @@ async Task ExecRepackImpl(List entries, bool useAsync) async Task ExecRepackCoreImpl(List entries, bool useAsync) { - uint resolvedResolution = (uint)ctx.AtlasResolution; + uint resolvedResolution = (uint)SanitizeAtlasResolution(ctx.AtlasResolution); if (ctx.RepackResolutionMode == ResolutionMode.AutoFromTexelDensity) { double area = MeshAreaHelper.ComputeTotal3DAreaMeters( @@ -2013,7 +2013,9 @@ async Task ExecRepackCoreImpl(List entries, bool useAsync) // where the resolved value can differ by an octave from the user // setting. BenchmarkRecorder.Current?.SetResolvedAtlasResolution((int)resolvedResolution); - UvtLog.Info($"[Repack] {entries.Count} meshes, res={resolvedResolution}, pad={ctx.ShellPaddingPx}, bdr={ctx.BorderPaddingPx}"); + int safeShellPadding = SanitizePadding(ctx.ShellPaddingPx); + int safeBorderPadding = SanitizePadding(ctx.BorderPaddingPx); + UvtLog.Info($"[Repack] {entries.Count} meshes, res={resolvedResolution}, pad={safeShellPadding}, bdr={safeBorderPadding}"); var validEntries = new List(); var meshCopies = new List(); foreach (var e in entries) @@ -2030,8 +2032,8 @@ async Task ExecRepackCoreImpl(List entries, bool useAsync) var opts = RepackOptions.Default; opts.resolution = resolvedResolution; - opts.padding = (uint)ctx.ShellPaddingPx; - opts.borderPadding = (uint)ctx.BorderPaddingPx; + opts.padding = (uint)safeShellPadding; + opts.borderPadding = (uint)safeBorderPadding; opts.bruteForce = ctx.XatlasBruteForce; opts.rotateCharts = ctx.XatlasRotateCharts; opts.rotateChartsToAxis = ctx.XatlasRotateChartsToAxis; @@ -4462,9 +4464,9 @@ void TryLoadSettingsFromSidecar() var data = AssetDatabase.LoadAssetAtPath(selectedSidecarPath); if (data?.toolSettings == null) return; var s = data.toolSettings; - ctx.AtlasResolution = s.atlasResolution; - ctx.ShellPaddingPx = s.shellPaddingPx; - ctx.BorderPaddingPx = s.borderPaddingPx; + ctx.AtlasResolution = SanitizeAtlasResolution(s.atlasResolution); + ctx.ShellPaddingPx = SanitizePadding(s.shellPaddingPx); + ctx.BorderPaddingPx = SanitizePadding(s.borderPaddingPx); ctx.RepackPerMesh = s.repackPerMesh; symSplitThresholdMode = Enum.IsDefined(typeof(SymmetrySplitShells.ThresholdMode), s.symmetrySplitThresholdMode) ? (SymmetrySplitShells.ThresholdMode)s.symmetrySplitThresholdMode @@ -4472,7 +4474,28 @@ void TryLoadSettingsFromSidecar() SymmetrySplitShells.CurrentThresholdMode = symSplitThresholdMode; ctx.SourceLodIndex = Mathf.Clamp(s.sourceLodIndex, 0, Mathf.Max(0, ctx.LodCount - 1)); ctx.PipeSettings.saveNewMeshAssets = s.saveNewMeshAssets; - if (!string.IsNullOrEmpty(s.savePath)) ctx.PipeSettings.savePath = s.savePath; + if (IsSafeAssetFolderPath(s.savePath)) ctx.PipeSettings.savePath = s.savePath; + } + + // The UI exposes atlas resolution as a free IntField, so the ceiling is + // only here to keep a forged sidecar (or a typo) from turning into an + // absurd xatlas allocation. It is deliberately far above the 4096 the + // presets offer so manually typed values are never silently reduced. + static int SanitizeAtlasResolution(int resolution) => Mathf.Clamp(resolution, 64, 16384); + + static int SanitizePadding(int padding) => Mathf.Clamp(padding, 0, 16); + + static bool IsSafeAssetFolderPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + string normalized = path.Replace('\\', '/').TrimEnd('/'); + if (normalized != "Assets" && !normalized.StartsWith("Assets/", StringComparison.Ordinal)) + return false; + + var segments = normalized.Split('/'); + foreach (string segment in segments) + if (segment.Length == 0 || segment == "." || segment == "..") return false; + return true; } void SaveSettingsToSidecar() diff --git a/Tests/Editor/ToolSettingsValidationTests.cs b/Tests/Editor/ToolSettingsValidationTests.cs new file mode 100644 index 00000000..c2f52a49 --- /dev/null +++ b/Tests/Editor/ToolSettingsValidationTests.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using NUnit.Framework; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class ToolSettingsValidationTests + { + static object Invoke(string methodName, object value) + { + var method = typeof(LightmapTransferTool).GetMethod( + methodName, + BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, $"Missing validation method {methodName}"); + return method.Invoke(null, new[] { value }); + } + + [TestCase(-1, 64)] + [TestCase(0, 64)] + [TestCase(1024, 1024)] + [TestCase(8192, 8192)] + [TestCase(int.MaxValue, 16384)] + public void AtlasResolution_IsClampedToSupportedRange(int input, int expected) + => Assert.AreEqual(expected, Invoke("SanitizeAtlasResolution", input)); + + [TestCase(-1, 0)] + [TestCase(2, 2)] + [TestCase(int.MaxValue, 16)] + public void Padding_IsClampedToUiRange(int input, int expected) + => Assert.AreEqual(expected, Invoke("SanitizePadding", input)); + + [TestCase("Assets/Generated", true)] + [TestCase("Assets", true)] + [TestCase("Assets/../Library", false)] + [TestCase("/tmp/output", false)] + [TestCase("Packages/output", false)] + [TestCase("", false)] + public void SavePath_MustRemainInsideAssets(string input, bool expected) + => Assert.AreEqual(expected, Invoke("IsSafeAssetFolderPath", input)); + } +} diff --git a/Tests/Editor/ToolSettingsValidationTests.cs.meta b/Tests/Editor/ToolSettingsValidationTests.cs.meta new file mode 100644 index 00000000..8b81c4e0 --- /dev/null +++ b/Tests/Editor/ToolSettingsValidationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c39a43c862ed4fef8a8e38993935b321 From d333790635e1218a100dd5d89e983b6a20346af3 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:52:43 +0200 Subject: [PATCH 15/76] fix(replay): preserve optimized vertex colors during sidecar replay (#182) Store the optimized mesh colors in the sidecar and use them directly on replay, so merged and orphan vertices no longer end up black. The remap path stays as the fallback for legacy sidecars without color data. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 2 ++ Editor/Uv2AssetPostprocessor.cs | 14 ++++++++++---- Editor/Uv2DataAsset.cs | 3 +++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index f6906808..d32c977a 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -2623,6 +2623,7 @@ bool TryBuildSidecarEntry(MeshEntry entry, Mesh resultMesh, out MeshUv2Entry sid return false; var positions = sidecarMesh.vertices; + var colors = sidecarMesh.colors32; var uv0List = new List(); (entry.originalMesh ?? resultMesh).GetUVs(0, uv0List); @@ -2639,6 +2640,7 @@ bool TryBuildSidecarEntry(MeshEntry entry, Mesh resultMesh, out MeshUv2Entry sid edgeWelded = entry.wasEdgeWelded, vertPositions = positions, vertUv0 = uv0List.ToArray(), + optimizedColors = colors.Length == sidecarMesh.vertexCount ? colors : null, schemaVersion = Uv2DataAsset.CurrentSchemaVersion, toolVersion = Uv2DataAsset.ToolVersionStr, sourceFingerprint = fp, diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index e79c45ca..a16f9af6 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -723,7 +723,7 @@ static bool ReplayOptimization(Mesh mesh, MeshUv2Entry entry, bool stale = false // ── Build optimized arrays via remap ── // If ground-truth vertex data is available, use it DIRECTLY for positions/normals/tangents. // This completely bypasses the remap for geometry, eliminating all remap-related vertex errors. - // The remap is only used for UV channels and colors (which aren't stored in ground truth). + // The remap is only used for UV channels and as a fallback for legacy color data. bool hasGroundTruth = entry.optimizedPositions != null && entry.optimizedPositions.Length == optCount; var optPos = new Vector3[optCount]; @@ -750,13 +750,19 @@ static bool ReplayOptimization(Mesh mesh, MeshUv2Entry entry, bool stale = false System.Array.Copy(entry.optimizedNormals, optNormals, optCount); if (optTangents != null && entry.optimizedTangents != null && entry.optimizedTangents.Length == optCount) System.Array.Copy(entry.optimizedTangents, optTangents, optCount); - - // UV channels, colors — from remap (UV0 is modified by weld, must come from raw FBX) + if (optColors != null && entry.optimizedColors != null && entry.optimizedColors.Length == optCount) + System.Array.Copy(entry.optimizedColors, optColors, optCount); + + // UV channels and legacy colors — from remap (UV0 is modified by weld, + // so it must come from the raw FBX). Optimized colors remain authoritative + // because merged and orphan vertices cannot always be reconstructed by remap. + bool remapColors = optColors != null && + (entry.optimizedColors == null || entry.optimizedColors.Length != optCount); for (int i = 0; i < rawCount; i++) { int dst = remap[i]; if (dst < 0) continue; - if (optColors != null) optColors[dst] = rawColors[i]; + if (remapColors) optColors[dst] = rawColors[i]; for (int ch = 0; ch < 8; ch++) { if (ch == 1 || optUvs[ch] == null) continue; diff --git a/Editor/Uv2DataAsset.cs b/Editor/Uv2DataAsset.cs index dab5eb79..17aafab9 100644 --- a/Editor/Uv2DataAsset.cs +++ b/Editor/Uv2DataAsset.cs @@ -175,6 +175,8 @@ public class MeshUv2Entry public Vector3[] optimizedNormals; /// All vertex tangents from the optimized mesh at Apply time. public Vector4[] optimizedTangents; + /// All vertex colors from the optimized mesh at Apply time. + public Color32[] optimizedColors; // ── Shell descriptors (v0.14.0+) ── /// Stable shell descriptors for the target mesh UV0 shells. @@ -418,6 +420,7 @@ static void CopyEntryFields(MeshUv2Entry src, MeshUv2Entry dst) dst.optimizedPositions = src.optimizedPositions; dst.optimizedNormals = src.optimizedNormals; dst.optimizedTangents = src.optimizedTangents; + dst.optimizedColors = src.optimizedColors; dst.shellDescriptors = src.shellDescriptors; dst.vertexToSourceShellDescriptor = src.vertexToSourceShellDescriptor; dst.targetShellToSourceShellDescriptor = src.targetShellToSourceShellDescriptor; From b8e7c8b732092cf747926a289b871429e334c822 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:39:07 +0200 Subject: [PATCH 16/76] fix(replay): validate sidecar UV replay channels (#139) Reject sidecar UV channel indices outside Mesh.SetUVs' 0..7 range before replay: the primary channel falls back to UV2, the auxiliary channel is skipped with a warning. Channel 0 stays valid because AO bakes can legitimately target UV0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Uv2AssetPostprocessor.cs | 35 +++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index a16f9af6..571f3343 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -12,6 +12,13 @@ namespace SashaRX.UnityMeshLab { public class Uv2AssetPostprocessor : AssetPostprocessor { + // Mesh.SetUVs only accepts channels 0..7. Channel 0 is a legitimate + // target (AO bakes can write UV0), so the valid range starts at 0 and + // only out-of-range values fall back to the UV2 default. + const int MinReplayUvChannel = 0; + const int MaxReplayUvChannel = 7; + const int DefaultReplayUvChannel = 1; + // Run well after Bakery and other postprocessors (default order = 0). public override int GetPostprocessOrder() => 10000; @@ -544,7 +551,7 @@ static bool ApplyEntryToMesh(Uv2DataAsset data, Mesh mesh, out ApplyStats stats) bool didRemap; int nearestFallback, nearestAnyReuse, unmatched; - int primaryTargetUvChannel = entry.targetUvChannel >= 0 ? entry.targetUvChannel : 1; + int primaryTargetUvChannel = ResolvePrimaryTargetUvChannel(entry.targetUvChannel, mesh.name); string primaryLabel = $"channel {primaryTargetUvChannel}"; var uv2 = RemapUvSetIfNeeded(entry, entry.uv2, mesh, primaryLabel, out didRemap, out nearestFallback, out nearestAnyReuse, out unmatched); stats.remapped = didRemap; @@ -552,7 +559,8 @@ static bool ApplyEntryToMesh(Uv2DataAsset data, Mesh mesh, out ApplyStats stats) stats.nearestAnyReuseCount = nearestAnyReuse; stats.unmatchedVerts = unmatched; mesh.SetUVs(primaryTargetUvChannel, uv2); - if (entry.auxiliaryUv != null && entry.auxiliaryTargetUvChannel >= 0) + if (entry.auxiliaryUv != null && + IsValidReplayUvChannel(entry.auxiliaryTargetUvChannel, mesh.name, "auxiliary")) { bool auxDidRemap; int auxNearestFallback, auxNearestAnyReuse, auxUnmatched; @@ -575,7 +583,7 @@ static void ApplyStoredUvChannelsDirect(Mesh mesh, MeshUv2Entry entry) { if (mesh == null || entry?.uv2 == null) return; - int primaryTargetUvChannel = entry.targetUvChannel >= 0 ? entry.targetUvChannel : 1; + int primaryTargetUvChannel = ResolvePrimaryTargetUvChannel(entry.targetUvChannel, mesh.name); if (entry.uv2.Length == mesh.vertexCount) mesh.SetUVs(primaryTargetUvChannel, entry.uv2); @@ -585,11 +593,30 @@ static void ApplyStoredUvChannelsDirect(Mesh mesh, MeshUv2Entry entry) static void ApplyAuxiliaryUvChannel(Mesh mesh, MeshUv2Entry entry) { if (mesh == null || entry?.auxiliaryUv == null) return; - if (entry.auxiliaryTargetUvChannel < 0) return; + if (!IsValidReplayUvChannel(entry.auxiliaryTargetUvChannel, mesh.name, "auxiliary")) return; if (entry.auxiliaryUv.Length != mesh.vertexCount) return; mesh.SetUVs(entry.auxiliaryTargetUvChannel, entry.auxiliaryUv); } + static int ResolvePrimaryTargetUvChannel(int channel, string meshName) + { + if (channel >= MinReplayUvChannel && channel <= MaxReplayUvChannel) + return channel; + + UvtLog.Warn($"[UV2 Postprocess] '{meshName}': sidecar primary UV channel {channel} is invalid; " + + "using channel 1 (UV2) instead."); + return DefaultReplayUvChannel; + } + + static bool IsValidReplayUvChannel(int channel, string meshName, string label) + { + if (channel >= MinReplayUvChannel && channel <= MaxReplayUvChannel) + return true; + + UvtLog.Warn($"[UV2 Postprocess] '{meshName}': sidecar {label} UV channel {channel} is invalid; skipped."); + return false; + } + static bool ResolveSymmetrySplitStep(MeshUv2Entry entry) { if (entry == null) return false; From e6ef72bb1c133e7e059d7052f1810231f322559e Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:36:36 +0200 Subject: [PATCH 17/76] fix(replay): validate sidecar submesh triangle counts (#126) Reject negative or non-multiple-of-three submesh index counts and accumulate the running total as long, so crafted counts can no longer wrap past the equality guard into a bad allocation. Bumps the postprocessor version so affected models are reimported. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Uv2AssetPostprocessor.cs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index 571f3343..74bb9d84 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -22,6 +22,11 @@ public class Uv2AssetPostprocessor : AssetPostprocessor // Run well after Bakery and other postprocessors (default order = 0). public override int GetPostprocessOrder() => 10000; + // Bump whenever replay behaviour changes so Unity reimports models that + // were processed by an older version of this postprocessor. Each bump + // forces a full reimport, so change it at most once per release. + public override uint GetVersion() => 2; + /// /// Paths to bypass during the next reimport. When ApplyUv2ToFbx needs the /// raw FBX mesh (before any postprocessor modifications), it adds the FBX @@ -682,9 +687,26 @@ static bool ReplayOptimization(Mesh mesh, MeshUv2Entry entry, bool stale = false return false; } - // Check sum of submesh tri counts matches optimizedTriangles length - int totalTriIndices = 0; - foreach (int c in entry.submeshTriangleCounts) totalTriIndices += c; + // Validate each submesh count before it is used for allocation/copying. + // Accumulate as long so crafted counts cannot wrap around to a valid total. + long totalTriIndices = 0; + foreach (int c in entry.submeshTriangleCounts) + { + if (c < 0 || c % 3 != 0) + { + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': invalid submesh triangle-index count " + + $"({c}) — replay aborted."); + return false; + } + + totalTriIndices += c; + if (totalTriIndices > entry.optimizedTriangles.Length) + { + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': submesh triangle-index counts exceed " + + $"optimizedTriangles.Length ({entry.optimizedTriangles.Length}) — replay aborted."); + return false; + } + } if (totalTriIndices != entry.optimizedTriangles.Length) { UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': sum(submeshTriCounts)={totalTriIndices} != " + From 95eef919d94c5914582d8ab83a82dd205009cdfe Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:53:25 +0200 Subject: [PATCH 18/76] fix(replay): abort incomplete stale remap rebuilds (#188) A partially rebuilt remap left zeroed optimized vertices still referenced by restored triangles. Abort the replay instead, and stop counting legitimate -1 entries as matches. The quadratic nearest-neighbour pass 2 is removed with it: sidecars are imported automatically, so that scan can stall the Editor. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Uv2AssetPostprocessor.cs | 38 ++++++++------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index 74bb9d84..31d2c086 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -1158,45 +1158,25 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) if (bestOld >= 0) { newRemap[i] = origRemap[bestOld]; + matched++; if (origRemap[bestOld] >= 0) { coveredOpt[origRemap[bestOld]] = true; - matched++; } } } - // Pass 2: nearest-neighbor fallback for bucket boundary misses. - // Allow reuse of already-used stored vertices here — these are rare - // boundary cases where the quantization rounded differently, and the - // nearest stored vertex (same position) should have the same origRemap. + // A partial remap is unsafe: replay would leave optimized vertices at + // their default values while still restoring triangles that reference + // them. Abort instead of applying corrupt geometry. Do not fall back to + // an all-pairs nearest-neighbor scan here; sidecars are imported + // automatically, so that quadratic work can stall the Editor. if (matched < newCount) { - for (int i = 0; i < newCount; i++) - { - if (newRemap[i] >= 0) continue; - float bestDist = float.MaxValue; - int bestOld = -1; - for (int j = 0; j < storedCount; j++) - { - float d = Vector3.SqrMagnitude(newPos[i] - storedPos[j]); - if (d < bestDist) { bestDist = d; bestOld = j; } - } - if (bestOld >= 0 && bestDist < 1e-4f) - { - newRemap[i] = origRemap[bestOld]; - if (origRemap[bestOld] >= 0) - { - coveredOpt[origRemap[bestOld]] = true; - matched++; - } - } - } - } - - if (matched < newCount) UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': remap rebuild matched {matched}/{newCount} " + - $"({newCount - matched} unmapped)"); + $"({newCount - matched} unmapped) — replay aborted."); + return null; + } // Coverage diagnostic: count how many referenced opt indices are still uncovered int uncoveredCount = 0; From dcfeddd996d66701457857e7bccc847472c0710a Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:52:57 +0200 Subject: [PATCH 19/76] fix(replay): bound sidecar vertex remapping work (#185) Cap candidate comparisons in PickBestCandidate and RemapUvSetIfNeeded so degenerate sidecars cannot make an import quadratic, replace the duplicate-bucket rescan with a per-bucket cursor, and make the unused-sidecar fallback use swap-remove instead of List.RemoveAt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Uv2AssetPostprocessor.cs | 41 ++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index 31d2c086..f9d339fa 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -19,6 +19,10 @@ public class Uv2AssetPostprocessor : AssetPostprocessor const int MaxReplayUvChannel = 7; const int DefaultReplayUvChannel = 1; + // Serialized sidecars are untrusted import inputs. Bound expensive candidate + // comparisons so coincident/quantized vertices cannot make imports quadratic. + const int MaxRemapCandidateChecks = 1000000; + // Run well after Bakery and other postprocessors (default order = 0). public override int GetPostprocessOrder() => 10000; @@ -1146,6 +1150,7 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) for (int i = 0; i < newCount; i++) newRemap[i] = -1; var coveredOpt = new bool[optCount > 0 ? optCount : 1]; int matched = 0; + int candidateChecksRemaining = MaxRemapCandidateChecks; for (int i = 0; i < newCount; i++) { @@ -1153,7 +1158,7 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) if (!posLookup.TryGetValue(key, out var candidates)) continue; int bestOld = PickBestCandidate(candidates, i, origRemap, coveredOpt, - hasUv0 ? newUv0 : null, storedUv0); + hasUv0 ? newUv0 : null, storedUv0, ref candidateChecksRemaining); if (bestOld >= 0) { @@ -1166,6 +1171,10 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) } } + if (candidateChecksRemaining <= 0) + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': remap rebuild comparison limit reached; " + + "remaining vertices were left unmapped."); + // A partial remap is unsafe: replay would leave optimized vertices at // their default values while still restoring triangles that reference // them. Abort instead of applying corrupt geometry. Do not fall back to @@ -1209,7 +1218,8 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) /// just because their opt slot is already covered. /// static int PickBestCandidate(List candidates, int newIdx, int[] origRemap, bool[] coveredOpt, - List newUv0, Vector2[] storedUv0) + List newUv0, Vector2[] storedUv0, + ref int candidateChecksRemaining) { if (candidates.Count == 1) { @@ -1224,6 +1234,7 @@ static int PickBestCandidate(List candidates, int newIdx, int[] origRemap, float bestUv0Dist = float.MaxValue; for (int k = 0; k < candidates.Count; k++) { + if (candidateChecksRemaining-- <= 0) return -1; int ci = candidates[k]; if (origRemap[ci] < 0) continue; float d = hasUv ? Vector2.SqrMagnitude(newUv0[newIdx] - storedUv0[ci]) : 0f; @@ -1244,6 +1255,7 @@ static int PickBestCandidate(List candidates, int newIdx, int[] origRemap, for (int k = 0; k < candidates.Count; k++) { + if (candidateChecksRemaining-- <= 0) return -1; int ci = candidates[k]; int opt = origRemap[ci]; @@ -1339,7 +1351,9 @@ static Vector2[] RemapUvSetIfNeeded( var result = new Vector2[count]; var used = new bool[sourceUv.Length]; var meshMatched = new bool[count]; + var candidateCursors = new Dictionary<(int, int, int), int>(); int matched = 0; + int candidateChecksRemaining = MaxRemapCandidateChecks; for (int i = 0; i < count; i++) { @@ -1364,6 +1378,7 @@ static Vector2[] RemapUvSetIfNeeded( int bestIdx = -1; foreach (int ci in candidates) { + if (candidateChecksRemaining-- <= 0) break; if (used[ci]) continue; float d = Vector2.SqrMagnitude(meshUv0[i] - entry.vertUv0[ci]); if (d < bestDist) @@ -1380,11 +1395,15 @@ static Vector2[] RemapUvSetIfNeeded( matched++; } } - else + + // Missing UV0, or a deliberately huge duplicate bucket: use the + // next unused index in this bucket. The cursor makes this linear. + if (!meshMatched[i] && (!hasUv0 || candidateChecksRemaining <= 0)) { - // No UV0 data — pick first unused candidate - foreach (int ci in candidates) + candidateCursors.TryGetValue(key, out int cursor); + while (cursor < candidates.Count) { + int ci = candidates[cursor++]; if (!used[ci]) { result[i] = sourceUv[ci]; @@ -1394,6 +1413,7 @@ static Vector2[] RemapUvSetIfNeeded( break; } } + candidateCursors[key] = cursor; } } @@ -1415,6 +1435,7 @@ static Vector2[] RemapUvSetIfNeeded( int fallbackMatched = 0; foreach (int mi in unmatchedMesh) { + if (candidateChecksRemaining <= 0) break; float bestDist = float.MaxValue; int bestIdx = -1; int bestListIdx = -1; @@ -1422,6 +1443,7 @@ static Vector2[] RemapUvSetIfNeeded( // First try: nearest UNUSED sidecar vertex within tight tolerance (1mm) for (int j = 0; j < unusedSidecar.Count; j++) { + if (candidateChecksRemaining-- <= 0) break; int si = unusedSidecar[j]; float d = Vector3.SqrMagnitude(meshPos[mi] - entry.vertPositions[si]); if (d < bestDist) @@ -1436,7 +1458,9 @@ static Vector2[] RemapUvSetIfNeeded( { result[mi] = sourceUv[bestIdx]; used[bestIdx] = true; - unusedSidecar.RemoveAt(bestListIdx); + int lastListIdx = unusedSidecar.Count - 1; + unusedSidecar[bestListIdx] = unusedSidecar[lastListIdx]; + unusedSidecar.RemoveAt(lastListIdx); matched++; fallbackMatched++; nearestFallbackCount++; @@ -1451,6 +1475,7 @@ static Vector2[] RemapUvSetIfNeeded( bestIdx = -1; for (int si = 0; si < entry.vertPositions.Length; si++) { + if (candidateChecksRemaining-- <= 0) break; float d = Vector3.SqrMagnitude(meshPos[mi] - entry.vertPositions[si]); if (d < bestDist) { bestDist = d; bestIdx = si; } } @@ -1463,6 +1488,10 @@ static Vector2[] RemapUvSetIfNeeded( } } + if (candidateChecksRemaining <= 0) + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': {channelLabel} remap comparison limit reached; " + + "remaining vertices will keep zero."); + if (nearestFallbackCount > 0) UvtLog.Info($"[UV2 Postprocess] '{mesh.name}': {nearestFallbackCount} {channelLabel} vertices matched by nearest-unused fallback"); From 5ade5c8e23fd21c552dfc38a06ea341ab2d40722 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:37:18 +0200 Subject: [PATCH 20/76] fix(arap): reject ARAP UVs outside float range (#134) Finite doubles above float.MaxValue cast to Infinity when packed into the flat UV buffer handed to native xatlas; reject them like other non-finite results and keep the original UVs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/ArapParameterization.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Editor/ArapParameterization.cs b/Editor/ArapParameterization.cs index 00d0a573..63da64a0 100644 --- a/Editor/ArapParameterization.cs +++ b/Editor/ArapParameterization.cs @@ -615,7 +615,9 @@ internal static bool Reparameterize( { outU[i] = (uLocal[i] - srcCx) * scale + targetCx; outV[i] = (vLocal[i] - srcCy) * scale + targetCy; - if (!IsFiniteD(outU[i]) || !IsFiniteD(outV[i])) return false; + if (!IsFiniteD(outU[i]) || !IsFiniteD(outV[i]) || + Math.Abs(outU[i]) > float.MaxValue || Math.Abs(outV[i]) > float.MaxValue) + return false; } for (int i = 0; i < n; i++) { From b80baff5b659b7d6051ec7af9a1255d6aeac9209 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:38:47 +0200 Subject: [PATCH 21/76] fix(benchmark): bound benchmark UV PNG generation (#137) Cap diagnostic UV2 snapshots per run, skip meshes above the UvPngWriter vertex/index limits, and reject oversized or negatively indexed input in UvPngWriter.Render. The snapshot budget is only consumed by meshes that actually yielded UV2 data. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/TRANSFER_BENCHMARK.md | 5 ++++- Editor/BenchmarkRecorder.cs | 30 ++++++++++++++++++++++++++-- Editor/UvPngWriter.cs | 14 ++++++++++--- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Documentation~/TRANSFER_BENCHMARK.md b/Documentation~/TRANSFER_BENCHMARK.md index 9cd0153b..f783590a 100644 --- a/Documentation~/TRANSFER_BENCHMARK.md +++ b/Documentation~/TRANSFER_BENCHMARK.md @@ -148,7 +148,10 @@ product (N = product of array lengths). Each cell: column. BenchmarkRecorder additionally dumps one PNG per recorded mesh into a sibling `{fileBase}_png/` folder, showing the result UV2 (repacked mesh on source LOD, transferred mesh on target LODs) with - per-shell coloring — so visual diffs between cells are immediate. + per-shell coloring — so visual diffs between cells are immediate. To keep + diagnostic output from exhausting Editor resources, each run writes at most + 32 snapshots and skips meshes above 200,000 UV vertices or 600,000 triangle + indices. Original atlas/padding values are restored when the sweep finishes or is cancelled. A progress bar with **Cancel** is shown during the sweep. diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index 9df87235..c9edb244 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -72,6 +72,9 @@ public sealed class BenchmarkRecorder : IDisposable // Per-mesh records (one row per recorded mesh) readonly List records = new List(); + const int MaxPngSnapshots = 32; + int pngSnapshotsCaptured; + int pngSnapshotsSkipped; BenchmarkRecorder(UvToolContext ctx, string label, bool splitTargetsFlag, SymmetrySplitShells.ThresholdMode symMode) @@ -168,16 +171,25 @@ public void RecordMesh(MeshEntry entry) Vector2[] uv2Snap = null; int[] trisSnap = null; - if (snapshotMesh != null) + if (snapshotMesh != null && pngSnapshotsCaptured < MaxPngSnapshots && + IsPngSnapshotWithinLimits(snapshotMesh)) { var list = new System.Collections.Generic.List(); snapshotMesh.GetUVs(1, list); if (list.Count > 0) { + // Only a mesh that actually yielded UV2 data consumes the + // snapshot budget; GetUVs on a mesh without UV2 is cheap and + // must not starve later meshes that do have UV2. + pngSnapshotsCaptured++; uv2Snap = list.ToArray(); trisSnap = snapshotMesh.triangles; } } + else if (snapshotMesh != null) + { + pngSnapshotsSkipped++; + } // Validation report can be stale: a mesh that failed transfer in // a later sweep cell would otherwise carry the previous cell's @@ -250,6 +262,19 @@ public void RecordMesh(MeshEntry entry) records.Add(rec); } + static bool IsPngSnapshotWithinLimits(Mesh mesh) + { + if (mesh.vertexCount > UvPngWriter.MaxUvCount) return false; + + ulong indexCount = 0; + for (int subMesh = 0; subMesh < mesh.subMeshCount; subMesh++) + { + indexCount += mesh.GetIndexCount(subMesh); + if (indexCount > UvPngWriter.MaxTriangleIndexCount) return false; + } + return indexCount >= 3; + } + // ── Dispose writes artefacts ── public void Dispose() { @@ -307,7 +332,8 @@ void WriteArtefacts() } UvtLog.Info(UvtLog.Category.Benchmark, - $"saved {records.Count} rec(s){(pngCount > 0 ? $" + {pngCount} PNG" : "")} → {csvPath}"); + $"saved {records.Count} rec(s){(pngCount > 0 ? $" + {pngCount} PNG" : "")}" + + $"{(pngSnapshotsSkipped > 0 ? $" ({pngSnapshotsSkipped} PNG skipped by safety limits)" : "")} → {csvPath}"); } string BuildCsv() diff --git a/Editor/UvPngWriter.cs b/Editor/UvPngWriter.cs index 2b558328..aa3cfa2a 100644 --- a/Editor/UvPngWriter.cs +++ b/Editor/UvPngWriter.cs @@ -11,6 +11,11 @@ namespace SashaRX.UnityMeshLab internal static class UvPngWriter { public const int DefaultSize = 1024; + // Rendering is diagnostic output, so reject inputs large enough to stall the + // editor rather than trying to visualise arbitrary asset-sized topology. + public const int MaxUvCount = 200000; + public const int MaxTriangleIndexCount = 600000; + public const int MaxSize = 2048; const float UvLo = -0.1f, UvHi = 1.1f; // show OOB verts around the 0-1 box static readonly Color[] Palette = @@ -42,7 +47,10 @@ static Material GetMat() /// public static bool Render(string path, Vector2[] uv, int[] tris, int size = DefaultSize) { - if (string.IsNullOrEmpty(path) || uv == null || tris == null || tris.Length < 3) return false; + if (string.IsNullOrEmpty(path) || uv == null || tris == null || + uv.Length > MaxUvCount || tris.Length < 3 || + tris.Length > MaxTriangleIndexCount || size <= 0 || size > MaxSize) + return false; var mat = GetMat(); if (mat == null) return false; @@ -75,7 +83,7 @@ public static bool Render(string path, Vector2[] uv, int[] tris, int size = Defa for (int f = 0; f < fN; f++) { int a = tris[f * 3], b = tris[f * 3 + 1], c = tris[f * 3 + 2]; - if (a >= uv.Length || b >= uv.Length || c >= uv.Length) continue; + if (a < 0 || b < 0 || c < 0 || a >= uv.Length || b >= uv.Length || c >= uv.Length) continue; int sid = faceToShell != null ? faceToShell[f] : 0; var col = Palette[Mathf.Abs(sid) % Palette.Length]; col.a = 0.5f; @@ -90,7 +98,7 @@ public static bool Render(string path, Vector2[] uv, int[] tris, int size = Defa for (int f = 0; f < fN; f++) { int a = tris[f * 3], b = tris[f * 3 + 1], c = tris[f * 3 + 2]; - if (a >= uv.Length || b >= uv.Length || c >= uv.Length) continue; + if (a < 0 || b < 0 || c < 0 || a >= uv.Length || b >= uv.Length || c >= uv.Length) continue; Vert(uv[a], size); Vert(uv[b], size); Vert(uv[b], size); Vert(uv[c], size); Vert(uv[c], size); Vert(uv[a], size); From b4e9f780aaacd03aa0eb94ae1b10e724f52776b7 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:43:18 +0200 Subject: [PATCH 22/76] fix(transfer): drop partial UV2 output on cancellation (#156) TransferResult.uv2 is allocated before the cancel checkpoints, so a cancelled transfer returned a zero-filled UV2 array that callers wrote to the mesh. Route every checkpoint through CancelTransfer, which restores the null-UV2 failure contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/GroupedShellTransfer.cs | 17 ++++++++++++----- Tests/Editor/XatlasRepackGroupMergeTests.cs | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 297ae1b9..827d5568 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -245,6 +245,13 @@ public static SimilarityTransform ComputeSimilarityTransform( return result; } + static TransferResult CancelTransfer(TransferResult result) + { + // A null UV2 array is the existing failure contract consumed by transfer callers. + result.uv2 = null; + return result; + } + // ═══════════════════════════════════════════════════════════ // AnalyzeSource — extract UV0 shells for UI display // ═══════════════════════════════════════════════════════════ @@ -912,7 +919,7 @@ static TransferResult TransferCore( // ── Phase 1: Extract shells ── UvProgress.ReportFromBackground($"'{targetMeshName}' · Phase 1 — extract shells"); - if (UvProgress.CancelRequested) return result; + if (UvProgress.CancelRequested) return CancelTransfer(result); var srcShells = UvShellExtractor.Extract(srcUv0, srcTris); var tgtShells = UvShellExtractor.Extract(tUv0, tgtTris); @@ -1020,7 +1027,7 @@ static TransferResult TransferCore( // ── Phase 1b: Precompute similarity transform per source shell ── UvProgress.ReportFromBackground($"'{targetMeshName}' · Phase 1b — transforms ({srcShells.Count} src)"); - if (UvProgress.CancelRequested) return result; + if (UvProgress.CancelRequested) return CancelTransfer(result); var srcTransforms = new SimilarityTransform[srcShells.Count]; for (int si = 0; si < srcShells.Count; si++) { @@ -1200,7 +1207,7 @@ static TransferResult TransferCore( // ── Phase 2a: Match each target shell → best source shell ── UvProgress.ReportFromBackground($"'{targetMeshName}' · Phase 2a — match {tgtShells.Count} targets"); - if (UvProgress.CancelRequested) return result; + if (UvProgress.CancelRequested) return CancelTransfer(result); result.targetShellToSourceShell = new int[tgtShells.Count]; result.targetShellMethod = new int[tgtShells.Count]; // 0=interp, 1=xform, 2=merged result.targetShellCentroids = new Vector3[tgtShells.Count]; @@ -1406,7 +1413,7 @@ static TransferResult TransferCore( // ── Phase 2b: Deduplicate — resolve same-source conflicts ── UvProgress.ReportFromBackground($"'{targetMeshName}' · Phase 2b — dedup"); - if (UvProgress.CancelRequested) return result; + if (UvProgress.CancelRequested) return CancelTransfer(result); // When multiple non-merged target shells claim the same source shell // (common with overlapping/tiling UV0), keep the best match and // reassign others to different source shells at the same 3D location. @@ -1918,7 +1925,7 @@ static TransferResult TransferCore( // ── Phase 3: Transfer UV2 using final source assignments ── UvProgress.ReportFromBackground($"'{targetMeshName}' · Phase 3 — transfer {vertCount} verts"); - if (UvProgress.CancelRequested) return result; + if (UvProgress.CancelRequested) return CancelTransfer(result); // Verbose: dump per-shell matching for diagnostics for (int tsi = 0; tsi < tgtShells.Count; tsi++) { diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs index d367facb..628cbcf0 100644 --- a/Tests/Editor/XatlasRepackGroupMergeTests.cs +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -194,6 +194,26 @@ public void PackCost_SaturatesInsteadOfOverflowing() public class GroupedShellTransferTests { + [Test] + public void CancelTransfer_InvalidatesPartialUv2Result() + { + var method = typeof(GroupedShellTransfer).GetMethod( + "CancelTransfer", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "GroupedShellTransfer should invalidate partial results on cancellation"); + + var partial = new GroupedShellTransfer.TransferResult + { + uv2 = new Vector2[4], + verticesTotal = 4 + }; + var cancelled = (GroupedShellTransfer.TransferResult)method.Invoke(null, new object[] { partial }); + + Assert.AreSame(partial, cancelled); + Assert.IsNull(cancelled.uv2, + "Cancelled transfers must use the null-UV2 failure contract expected by transfer callers."); + } + [Test] public void Uv2PixelMargin_ScalesFromResolvedAtlasSize() { From c021b9e8f13d0f5e5f1bf5f57b35f5b50ea9dd70 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:36:47 +0200 Subject: [PATCH 23/76] fix(repack): prevent concurrent xatlas native sessions (#128) Async repack yields back to the editor while the native bridge still owns a single process-global atlas, so a second entry could destroy an in-flight atlas. Guard every managed session with a fail-fast reentrancy flag released after xatlasDestroy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 11 +++++++++++ Editor/XatlasRepack.cs | 30 +++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 1c1de69d..32e98e42 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -21,6 +21,17 @@ 4. Документировать результат здесь ДО мержа. 5. Если ломает — реверт. Не компенсировать другим фиксом. +## Исправление 2026-08-06 — исключительный доступ к native xatlas session + +- **Проблема:** async repack оставляет Editor отзывчивым, поэтому параллельный + вызов мог заменить или уничтожить единственный глобальный `s_atlas`, пока + background task ещё выполнял native pack. +- **Изменение:** все managed-входы в xatlas session используют общий atomic + fail-fast guard; повторный вызов отклоняется до `xatlasCreate`, а guard + освобождается в `finally` после `xatlasDestroy`. +- **Ожидание/проверка:** обычные sync/async repack сохраняют прежний результат; + перекрывающиеся вызовы больше не входят в native bridge одновременно. + ## Что НЕ работает (уроки из 5 отклонённых PR и ~10 ревертов) - **Affine UV0→UV2 mapping** → экстраполяция за пределы шелов → overlap (PR #48, reverted) diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index 701dfd21..f1c220ed 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -184,6 +184,23 @@ public struct RepackResult public static class XatlasRepack { + // The native bridge owns a single process-global atlas pointer. Keep + // every managed xatlas session exclusive: async packing yields back + // to the editor, so UI or API callers can otherwise destroy an atlas + // while a background pack is still using it. + static int s_nativeSessionInFlight; + + static void AcquireNativeSession() + { + if (System.Threading.Interlocked.CompareExchange( + ref s_nativeSessionInFlight, 1, 0) != 0) + throw new InvalidOperationException( + "An xatlas repack operation is already in progress."); + } + + static void ReleaseNativeSession() + => System.Threading.Volatile.Write(ref s_nativeSessionInFlight, 0); + const uint ORPHAN_CHART = uint.MaxValue; const long kBruteCostBudget = 500_000_000L; // ~5-10s wall const long kHeuristicCostBudget = 20_000_000_000L; // ~30-60s wall @@ -1117,10 +1134,10 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) uint xatlasFaceCount = (uint)faceCount; // ── xatlas pipeline ── - XatlasNative.xatlasCreate(); - + AcquireNativeSession(); try { + XatlasNative.xatlasCreate(); int addErr = XatlasNative.xatlasAddUvMesh( uvFlat, (uint)vertCount, indices, (uint)indices.Length, @@ -1274,7 +1291,8 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) } finally { - XatlasNative.xatlasDestroy(); + try { XatlasNative.xatlasDestroy(); } + finally { ReleaseNativeSession(); } } return result; @@ -1381,9 +1399,10 @@ static async Task RepackMultiCore(Mesh[] meshes, RepackOptions o var allUvFlat = new float[meshCount][]; // ── Single xatlas session for all meshes ── - XatlasNative.xatlasCreate(); + AcquireNativeSession(); try { + XatlasNative.xatlasCreate(); // Add all meshes for (int m = 0; m < meshCount; m++) { @@ -1611,7 +1630,8 @@ static async Task RepackMultiCore(Mesh[] meshes, RepackOptions o } finally { - XatlasNative.xatlasDestroy(); + try { XatlasNative.xatlasDestroy(); } + finally { ReleaseNativeSession(); } } return results; From 11474a5dc8c011624a4c7b04f7d830a695b68a22 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:39:03 +0200 Subject: [PATCH 24/76] fix(benchmark): validate sweep parameters before running (#138) Reject negative padding (which underflows to a huge uint in native xatlas), NaN/out-of-range stretch thresholds, out-of-range ARAP iterations, and cartesian products that explode the cell count. Surface the reason in the sweep UI and refuse to start. Also states the 0..200 ARAP range in the suite tooltip (folded in from #133). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Settings/TestSuiteAsset.cs | 2 +- Editor/Tools/LightmapTransferTool.cs | 104 +++++++++++++++++--- Tests/Editor/XatlasRepackGroupMergeTests.cs | 50 ++++++++++ 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/Editor/Settings/TestSuiteAsset.cs b/Editor/Settings/TestSuiteAsset.cs index 06505ebe..8deb5222 100644 --- a/Editor/Settings/TestSuiteAsset.cs +++ b/Editor/Settings/TestSuiteAsset.cs @@ -105,7 +105,7 @@ public class SweepMatrix [Tooltip("ARAP stretched-shell reparameterization variants. 0 = OFF; >0 = ON with that many " + "local-global iterations. Default {0} (ARAP off — opt in by adding e.g. 50 or 100 to " + - "this list). Each non-zero value spawns a separate sweep cell.")] + "this list). Allowed range is 0..200; each entry spawns a separate sweep cell.")] public int[] arapIterationsVariants = { 0, 50 }; [Tooltip("Sander L² stretch threshold variants used to gate ARAP. 1.0 = isometric; 1.5 = typical " + diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index d32c977a..2bcbfeef 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -44,6 +44,87 @@ static bool HasIncludedTransferTargets(IEnumerable entries, int sourc return false; } + const int MaxSweepValuesPerDimension = 16; + const int MaxSweepCells = 256; + + static bool TryValidateSweep(TestSuiteAsset.SweepMatrix sm, UvToolContext context, + out int cellCount, out string error) + { + cellCount = 0; + error = null; + if (sm == null || context == null) + { + error = "Sweep configuration is missing."; + return false; + } + + var resolutions = sm.atlasResolutions?.Length > 0 + ? sm.atlasResolutions : new[] { context.AtlasResolution }; + var shellPaddings = sm.shellPaddingPxVariants?.Length > 0 + ? sm.shellPaddingPxVariants : new[] { context.ShellPaddingPx }; + var borderPaddings = sm.borderPaddingPxVariants?.Length > 0 + ? sm.borderPaddingPxVariants : new[] { context.BorderPaddingPx }; + var arapIterations = sm.arapIterationsVariants?.Length > 0 + ? sm.arapIterationsVariants + : new[] { context.ReparameterizeStretchedShells ? context.ArapIterations : 0 }; + var stretchThresholds = sm.stretchThresholdVariants?.Length > 0 + ? sm.stretchThresholdVariants : new[] { context.StretchThreshold }; + + if (!ValidateSweepDimension(resolutions, 64, 4096, "atlas resolution", out error) || + !ValidateSweepDimension(shellPaddings, 0, 64, "shell padding", out error) || + !ValidateSweepDimension(borderPaddings, 0, 64, "border padding", out error) || + !ValidateSweepDimension(arapIterations, 0, 200, "ARAP iterations", out error) || + !ValidateSweepDimension(stretchThresholds, 1f, 3f, "stretch threshold", out error)) + return false; + + long total = (long)resolutions.Length * shellPaddings.Length * borderPaddings.Length + * arapIterations.Length * stretchThresholds.Length; + if (total > MaxSweepCells) + { + error = $"Sweep has {total} cells; the maximum is {MaxSweepCells}."; + return false; + } + + cellCount = (int)total; + return true; + } + + static bool ValidateSweepDimension(int[] values, int min, int max, string label, + out string error) + { + if (values.Length > MaxSweepValuesPerDimension) + { + error = $"{label} has {values.Length} values; the maximum is {MaxSweepValuesPerDimension}."; + return false; + } + foreach (int value in values) + if (value < min || value > max) + { + error = $"Invalid {label} {value}; allowed range is {min}..{max}."; + return false; + } + error = null; + return true; + } + + static bool ValidateSweepDimension(float[] values, float min, float max, string label, + out string error) + { + if (values.Length > MaxSweepValuesPerDimension) + { + error = $"{label} has {values.Length} values; the maximum is {MaxSweepValuesPerDimension}."; + return false; + } + foreach (float value in values) + if (float.IsNaN(value) || float.IsInfinity(value) || value < min || value > max) + { + error = $"Invalid {label} {value}; allowed range is {min}..{max}."; + return false; + } + error = null; + return true; + } + // ── Internal tab ── enum Tab { Setup, Repack, Transfer } Tab tab = Tab.Setup; @@ -491,17 +572,11 @@ void DrawSetupDebugSection() sweepSuite = (TestSuiteAsset)EditorGUILayout.ObjectField( "Sweep suite", sweepSuite, typeof(TestSuiteAsset), false); int cells = 0; + string sweepError = null; if (sweepSuite != null && sweepSuite.sweep != null) - { - var sm = sweepSuite.sweep; - int rL = sm.atlasResolutions?.Length ?? 0; - int pL = sm.shellPaddingPxVariants?.Length ?? 0; - int bL = sm.borderPaddingPxVariants?.Length ?? 0; - int arL = sm.arapIterationsVariants?.Length ?? 0; - int stL = sm.stretchThresholdVariants?.Length ?? 0; - cells = Mathf.Max(1, rL) * Mathf.Max(1, pL) * Mathf.Max(1, bL) - * Mathf.Max(1, arL) * Mathf.Max(1, stL); - } + TryValidateSweep(sweepSuite.sweep, ctx, out cells, out sweepError); + if (!string.IsNullOrEmpty(sweepError)) + EditorGUILayout.HelpBox(sweepError, MessageType.Error); using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(sweepSuite == null || cells == 0)) @@ -1456,6 +1531,12 @@ async Task ExecFullPipelineImpl(string runLabel, bool useAsync) void ExecSweep(TestSuiteAsset.SweepMatrix sm) { if (ctx.LodGroup == null || sm == null) return; + if (!TryValidateSweep(sm, ctx, out int total, out string validationError)) + { + UvtLog.Error(UvtLog.Category.Benchmark, $"[Sweep] {validationError}"); + EditorUtility.DisplayDialog("Invalid sweep", validationError, "OK"); + return; + } var resArr = (sm.atlasResolutions != null && sm.atlasResolutions.Length > 0) ? sm.atlasResolutions : new[] { ctx.AtlasResolution }; var padArr = (sm.shellPaddingPxVariants != null && sm.shellPaddingPxVariants.Length > 0) @@ -1468,9 +1549,6 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) var stretchArr = (sm.stretchThresholdVariants != null && sm.stretchThresholdVariants.Length > 0) ? sm.stretchThresholdVariants : new[] { ctx.StretchThreshold }; - int total = resArr.Length * padArr.Length * bdrArr.Length - * arapItersArr.Length * stretchArr.Length; - // Snapshot ctx fields we mutate — restored unconditionally below. int origRes = ctx.AtlasResolution; int origPad = ctx.ShellPaddingPx; diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs index 628cbcf0..1ec47ea7 100644 --- a/Tests/Editor/XatlasRepackGroupMergeTests.cs +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -233,6 +233,56 @@ public void Uv2PixelMargin_ScalesFromResolvedAtlasSize() public class LightmapTransferToolUiTests { + static bool ValidateSweep(TestSuiteAsset.SweepMatrix sweep, out int cells, out string error) + { + var method = typeof(LightmapTransferTool).GetMethod( + "TryValidateSweep", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "Sweep validation should remain available as a testable preflight"); + + object[] args = { sweep, new UvToolContext(), 0, null }; + bool valid = (bool)method.Invoke(null, args); + cells = (int)args[2]; + error = (string)args[3]; + return valid; + } + + [Test] + public void SweepValidation_RejectsUnsafeNativePackingValues() + { + var sweep = new TestSuiteAsset.SweepMatrix + { + shellPaddingPxVariants = new[] { -1 }, + }; + + Assert.IsFalse(ValidateSweep(sweep, out int cells, out string error)); + Assert.AreEqual(0, cells); + StringAssert.Contains("shell padding", error); + } + + [Test] + public void SweepValidation_RejectsExcessiveCartesianProduct() + { + var sweep = new TestSuiteAsset.SweepMatrix + { + atlasResolutions = new[] { 64, 128, 256, 512, 1024, 2048, 4096 }, + shellPaddingPxVariants = new[] { 0, 1, 2, 3, 4, 5, 6 }, + borderPaddingPxVariants = new[] { 0, 1, 2, 3, 4, 5 }, + }; + + Assert.IsFalse(ValidateSweep(sweep, out int cells, out string error)); + Assert.AreEqual(0, cells); + StringAssert.Contains("maximum is 256", error); + } + + [Test] + public void SweepValidation_AcceptsDefaultsAndCountsCellsSafely() + { + Assert.IsTrue(ValidateSweep(new TestSuiteAsset.SweepMatrix(), + out int cells, out string error), error); + Assert.AreEqual(24, cells); + } + [Test] public void BruteForceOption_IsUnavailable_WhenInternalOversampleIsAboveOne() { From 0b1d245e082f2e603f3ca63c657de77ad32cea4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:44:46 +0000 Subject: [PATCH 25/76] fix(repack): cache mesh area preview instead of scanning per repaint (#131) ComputeTotal3DAreaMeters copies mesh.vertices and every submesh index array; it ran at two OnGUI sites on every repaint. Cache the result keyed by the exact source-mesh references and recompute only on a mesh-set change; ExecRepackCoreImpl refreshes the same cache with the value it already computes. Labels stay live and the control count is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 64 +++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 2bcbfeef..fbfb8edf 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -19,6 +19,14 @@ public class LightmapTransferTool : IUvTool UvCanvasView canvas; Action requestRepaint; + // Surface-area scans materialize mesh vertex/index data and are far too + // expensive to run on every OnGUI repaint. Cache the result keyed by the + // exact mesh references it was computed from, and recompute only when + // that set changes (or when an actual repack refreshes it). + readonly List _areaPreviewMeshes = new List(); + double _areaPreview; + bool _hasAreaPreview; + public string ToolName => "UV2 Transfer"; public string ToolId => "uv2_transfer"; public int ToolOrder => 0; @@ -44,6 +52,46 @@ static bool HasIncludedTransferTargets(IEnumerable entries, int sourc return false; } + List GetRepackSourceMeshes() + { + return ctx.ForLod(ctx.SourceLodIndex) + .Where(e => e.originalMesh != null) + .Select(e => e.originalMesh) + .ToList(); + } + + bool TryGetAreaPreview(List meshes, out double area) + { + bool sameMeshes = _hasAreaPreview && meshes.Count == _areaPreviewMeshes.Count; + for (int i = 0; sameMeshes && i < meshes.Count; i++) + sameMeshes = ReferenceEquals(meshes[i], _areaPreviewMeshes[i]); + + area = sameMeshes ? _areaPreview : 0.0; + return sameMeshes; + } + + double CacheAreaPreview(List meshes, double area) + { + _areaPreview = area; + _areaPreviewMeshes.Clear(); + _areaPreviewMeshes.AddRange(meshes); + _hasAreaPreview = true; + return _areaPreview; + } + + /// + /// Cached total 3D surface area of the source-LOD meshes. Recomputed only + /// when the mesh set changes, so a repaint no longer copies every vertex + /// and index array. Draws no controls — the IMGUI control count must not + /// depend on cache state. + /// + double GetSourceAreaPreview() + { + var meshes = GetRepackSourceMeshes(); + if (TryGetAreaPreview(meshes, out double area)) return area; + return CacheAreaPreview(meshes, MeshAreaHelper.ComputeTotal3DAreaMeters(meshes)); + } + const int MaxSweepValuesPerDimension = 16; const int MaxSweepCells = 256; @@ -754,10 +802,7 @@ void DrawPipelineSection() // Texel density preview — live summary of the resolved // atlas size so the user sees what xatlas will actually // pack into without having to switch to the Repack tab. - double total3DArea = MeshAreaHelper.ComputeTotal3DAreaMeters( - ctx.ForLod(ctx.SourceLodIndex) - .Where(e => e.originalMesh != null) - .Select(e => e.originalMesh)); + double total3DArea = GetSourceAreaPreview(); string previewLine; if (ctx.RepackResolutionMode == ResolutionMode.AutoFromTexelDensity) { @@ -1019,10 +1064,7 @@ void DrawRepackResolutionControls() ? ResolutionMode.AutoFromTexelDensity : ResolutionMode.Manual; - double total3DArea = MeshAreaHelper.ComputeTotal3DAreaMeters( - ctx.ForLod(ctx.SourceLodIndex) - .Where(e => e.originalMesh != null) - .Select(e => e.originalMesh)); + double total3DArea = GetSourceAreaPreview(); if (ctx.RepackResolutionMode == ResolutionMode.Manual) { @@ -2078,8 +2120,10 @@ async Task ExecRepackCoreImpl(List entries, bool useAsync) uint resolvedResolution = (uint)SanitizeAtlasResolution(ctx.AtlasResolution); if (ctx.RepackResolutionMode == ResolutionMode.AutoFromTexelDensity) { - double area = MeshAreaHelper.ComputeTotal3DAreaMeters( - entries.Where(e => e.originalMesh != null).Select(e => e.originalMesh)); + var areaMeshes = entries.Where(e => e.originalMesh != null) + .Select(e => e.originalMesh).ToList(); + double area = CacheAreaPreview( + areaMeshes, MeshAreaHelper.ComputeTotal3DAreaMeters(areaMeshes)); resolvedResolution = MeshAreaHelper.ComputeAutoResolution( area, ctx.LightmapDensity, ctx.TargetUvCoverage); UvtLog.Info( From 49e1c876f9798ea1983c0d17c653e7e812d04100 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:44:59 +0200 Subject: [PATCH 26/76] fix(preview): guard shell color hash against int.MinValue (#168) Mathf.Abs(int.MinValue) throws OverflowException, and the centroid/bbox hash can produce it. Route all five palette-index sites in UvCanvasView and ShellColorModelPreview through a shared NonNegativeColorKey helper and mark the hash arithmetic unchecked. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Framework/UvCanvasView.cs | 10 ++++++++-- Editor/ShellColorModelPreview.cs | 12 +++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Editor/Framework/UvCanvasView.cs b/Editor/Framework/UvCanvasView.cs index d844d0c8..31f8c4b9 100644 --- a/Editor/Framework/UvCanvasView.cs +++ b/Editor/Framework/UvCanvasView.cs @@ -620,7 +620,7 @@ public void GlFillSh(UvToolContext ctx, float ox, float oy, float sz, Mesh mesh, { if (tot>=MAX_TRI) break; int colorKey = GetShellColorKey(ctx, s, entry); - Color c = pal[colorKey % pal.Length]; + Color c = pal[NonNegativeColorKey(colorKey) % pal.Length]; if (s.shellId == selectedShellId) c = Color.Lerp(c, Color.white, 0.45f); c.a = s.shellId == selectedShellId ? Mathf.Clamp01(FillAlpha * 1.85f) : FillAlpha; @@ -907,7 +907,13 @@ static int ShellBBoxHash(UvShell shell, Mesh mesh, int uvChannel = 1) // Include bbox size to distinguish overlapping shells of different sizes int qw = Mathf.RoundToInt((mx.x - mn.x) * 100f); int qh = Mathf.RoundToInt((mx.y - mn.y) * 100f); - return Mathf.Abs((qx * 73856093) ^ (qy * 19349663) ^ (qw * 83492791) ^ (qh * 41729381)); + int hash = unchecked((qx * 73856093) ^ (qy * 19349663) ^ (qw * 83492791) ^ (qh * 41729381)); + return NonNegativeColorKey(hash); + } + + static int NonNegativeColorKey(int colorKey) + { + return colorKey == int.MinValue ? int.MaxValue : Mathf.Abs(colorKey); } public int GetShellColorKey(UvToolContext ctx, UvShell shell, MeshEntry entry) diff --git a/Editor/ShellColorModelPreview.cs b/Editor/ShellColorModelPreview.cs index d4e5927f..bea0b024 100644 --- a/Editor/ShellColorModelPreview.cs +++ b/Editor/ShellColorModelPreview.cs @@ -86,7 +86,8 @@ static int[] BuildTriangleShellIds(Mesh mesh) int qy = Mathf.RoundToInt(center.y * 100f); int qw = Mathf.RoundToInt((mx.x - mn.x) * 100f); int qh = Mathf.RoundToInt((mx.y - mn.y) * 100f); - int stableKey = Mathf.Abs((qx * 73856093) ^ (qy * 19349663) ^ (qw * 83492791) ^ (qh * 41729381)); + int hash = unchecked((qx * 73856093) ^ (qy * 19349663) ^ (qw * 83492791) ^ (qh * 41729381)); + int stableKey = NonNegativeColorKey(hash); foreach (int faceIndex in shell.faceIndices) if (faceIndex >= 0 && faceIndex < triangleToShell.Length) @@ -103,6 +104,11 @@ static int[] BuildTriangleShellIds(Mesh mesh) public static bool IsActive => isActive; + static int NonNegativeColorKey(int colorKey) + { + return colorKey == int.MinValue ? int.MaxValue : Mathf.Abs(colorKey); + } + /// /// Apply with pre-computed face color keys (same logic as 2D preview). /// @@ -218,7 +224,7 @@ static Mesh BuildColorizedClone(Mesh sourceMesh, Color32[] palette, int[] faceCo int colorKey = (faceColorKeys != null && face < faceColorKeys.Length) ? faceColorKeys[face] : face; Color32 color = palette != null && palette.Length > 0 - ? palette[Mathf.Abs(colorKey) % palette.Length] + ? palette[NonNegativeColorKey(colorKey) % palette.Length] : new Color32(255, 255, 255, 255); int triBase = face * 3; @@ -259,7 +265,7 @@ static Mesh BuildColorizedClone(Mesh sourceMesh, Color32[] palette, PreviewShell { int shellId = shellIds[face]; Color32 color = palette != null && palette.Length > 0 - ? palette[Mathf.Abs(shellId) % palette.Length] + ? palette[NonNegativeColorKey(shellId) % palette.Length] : new Color32(255, 255, 255, 255); int triBase = face * 3; From 7dbedc9b1cc7160fc699d29809cdbe420393e78f Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:53:16 +0200 Subject: [PATCH 27/76] fix(transfer): bound grouped transfer overlap scan (#186) FindOverlapGroups is a quadratic shell-pair scan. Skip it above 512 source shells, log a warning, and fall back to the original fixed retry count so a highly fragmented mesh cannot stall the Editor. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/GroupedShellTransfer.cs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 827d5568..5ca9c8de 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -21,6 +21,11 @@ namespace SashaRX.UnityMeshLab { public static class GroupedShellTransfer { + // Keep overlap detection bounded: FindOverlapGroups compares every shell pair. + // Typical production meshes stay well below this limit; pathological meshes + // fall back to the original fixed retry count instead of stalling the Editor. + const int kMaxShellsForOverlapScan = 512; + // ─── Similarity Transform (4 params: a, b, tx, ty) ─── public struct SimilarityTransform { @@ -1187,12 +1192,21 @@ static TransferResult TransferCore( : 0.001f; float kUv0BadThreshold = Mathf.Max(avgUv0Edge * avgUv0Edge, 0.001f); - // Adaptive kMaxRetries based on overlap group size - var overlapGroups = UvShellExtractor.FindOverlapGroups(srcShells); + // Adaptive kMaxRetries based on overlap group size. The overlap detector is + // quadratic, so never run it for attacker-controlled, highly fragmented meshes. + bool scanOverlapGroups = srcShells.Count <= kMaxShellsForOverlapScan; + var overlapGroups = scanOverlapGroups + ? UvShellExtractor.FindOverlapGroups(srcShells) + : new List>(); int maxOverlapGroupSize = 0; foreach (var group in overlapGroups) maxOverlapGroupSize = Mathf.Max(maxOverlapGroupSize, group.Count); - int kMaxRetries = Mathf.Clamp(maxOverlapGroupSize + 2, 5, srcShells.Count); + int kMaxRetries = scanOverlapGroups + ? Mathf.Clamp(maxOverlapGroupSize + 2, 5, srcShells.Count) + : Mathf.Min(5, srcShells.Count); + + if (!scanOverlapGroups) + UvtLog.Warn($"[GroupedTransfer] Skipping quadratic UV overlap scan for {srcShells.Count} source shells (limit {kMaxShellsForOverlapScan})."); // Build overlap group membership: srcShell → list of all group members var srcShellOverlapMembers = new List[srcShells.Count]; From 79decd4820bf3f73dd678472d0d6e35e42daa41d Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:54:01 +0200 Subject: [PATCH 28/76] fix(partition): bound spatial overlap detection work (#190) SpatialPartitioner.DetectOverlap compared every face pair inside each grid cell, worst case O(gridCells * faces^2). Replace it with an inclusion-exclusion incidence count that is linear in face-cell memberships while preserving the shared-vertex exclusion contract. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 11 ++ Editor/SpatialPartitioner.cs | 114 ++++++++++++++----- Tests/Editor/SpatialPartitionerTests.cs | 74 ++++++++++++ Tests/Editor/SpatialPartitionerTests.cs.meta | 3 + 4 files changed, 172 insertions(+), 30 deletions(-) create mode 100644 Tests/Editor/SpatialPartitionerTests.cs create mode 100644 Tests/Editor/SpatialPartitionerTests.cs.meta diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 32e98e42..a1003274 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -32,6 +32,17 @@ - **Ожидание/проверка:** обычные sync/async repack сохраняют прежний результат; перекрывающиеся вызовы больше не входят в native bridge одновременно. +## Эксперимент 2026-08-06 — Линейная фильтрация overlap-ячеек + +- **Проблема:** попарная проверка всех face в каждой ячейке сетки имела худший случай + `O(gridCells * faces²)` и позволяла патологическому UV-мешу надолго блокировать Editor. +- **Изменение:** число face, имеющих общую вершину с текущим face, вычисляется линейно через + счётчики вершин, пар и троек (формула включений-исключений). Наличие остальных face означает overlap. +- **Сохранённый контракт:** соседние face с общей вершиной игнорируются, а не смежные face в общей + ячейке по-прежнему помечаются как overlap. +- **Проверка:** добавлены EditMode-тесты для обоих случаев; сложные модели необходимо проверить + локально в Unity Editor по стандартному протоколу. + ## Что НЕ работает (уроки из 5 отклонённых PR и ~10 ревертов) - **Affine UV0→UV2 mapping** → экстраполяция за пределы шелов → overlap (PR #48, reverted) diff --git a/Editor/SpatialPartitioner.cs b/Editor/SpatialPartitioner.cs index ebaf2438..059b1a64 100644 --- a/Editor/SpatialPartitioner.cs +++ b/Editor/SpatialPartitioner.cs @@ -70,13 +70,12 @@ public static ShellPartitionResult[] PartitionShells( continue; } - // Step 1: Build face adjacency (needed for both overlap detection and flood-fill) - var faceVerts = BuildFaceVertexSets(shell.faceIndices, triangles); + // Step 1: Build face adjacency for flood-fill var adjacency = BuildFaceAdjacency(shell.faceIndices, triangles); // Step 2: Overlap detection — only flag faces sharing a grid cell // with a NON-ADJACENT face (no shared vertex) - var overlappingFaces = DetectOverlap(shell, uv0, triangles, faceVerts); + var overlappingFaces = DetectOverlap(shell, uv0, triangles); r.hasOverlap = overlappingFaces.Count > 0; if (!r.hasOverlap) @@ -218,31 +217,38 @@ public static int[] GetPartitionFaces( return faces.ToArray(); } - // ════════════════════════════════════════════════════════════ - // Per-face vertex set (for fast adjacency check in overlap detection) - // ════════════════════════════════════════════════════════════ - - static Dictionary> BuildFaceVertexSets(List faceIndices, int[] triangles) + readonly struct FaceVertexKey : System.IEquatable { - var result = new Dictionary>(faceIndices.Count); - foreach (int f in faceIndices) + public readonly int a; + public readonly int b; + public readonly int c; + + public FaceVertexKey(int v0, int v1, int v2) { - var set = new HashSet(); - set.Add(triangles[f * 3]); - set.Add(triangles[f * 3 + 1]); - set.Add(triangles[f * 3 + 2]); - result[f] = set; + if (v0 > v1) { int t = v0; v0 = v1; v1 = t; } + if (v1 > v2) { int t = v1; v1 = v2; v2 = t; } + if (v0 > v1) { int t = v0; v0 = v1; v1 = t; } + a = v0; + b = v1; + c = v2; } - return result; - } - static bool FacesShareVertex(Dictionary> faceVerts, int fA, int fB) - { - if (!faceVerts.TryGetValue(fA, out var setA)) return false; - if (!faceVerts.TryGetValue(fB, out var setB)) return false; - foreach (int v in setA) - if (setB.Contains(v)) return true; - return false; + public bool Equals(FaceVertexKey other) + { + return a == other.a && b == other.b && c == other.c; + } + + public override bool Equals(object obj) => obj is FaceVertexKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int hash = a; + hash = (hash * 397) ^ b; + return (hash * 397) ^ c; + } + } } // ════════════════════════════════════════════════════════════ @@ -250,8 +256,7 @@ static bool FacesShareVertex(Dictionary> faceVerts, int fA, in // ════════════════════════════════════════════════════════════ static HashSet DetectOverlap( - UvShell shell, Vector2[] uv0, int[] triangles, - Dictionary> faceVerts) + UvShell shell, Vector2[] uv0, int[] triangles) { var overlapping = new HashSet(); @@ -294,27 +299,76 @@ static HashSet DetectOverlap( } } + // Count incidences instead of comparing every pair in a cell. For a + // face, inclusion-exclusion gives the number of cell faces sharing + // at least one of its vertices. Any remaining face is non-adjacent. + // This keeps detection linear in face-cell memberships even when a + // crafted mesh makes every face cover every grid cell. + var vertexCounts = new Dictionary(); + var pairCounts = new Dictionary(); + var tripleCounts = new Dictionary(); + foreach (var kv in cellFaces) { var list = kv.Value; if (list.Count < 2) continue; + vertexCounts.Clear(); + pairCounts.Clear(); + tripleCounts.Clear(); + + for (int i = 0; i < list.Count; i++) + { + int f = list[i]; + int i0 = triangles[f * 3], i1 = triangles[f * 3 + 1], i2 = triangles[f * 3 + 2]; + IncrementCount(vertexCounts, i0); + if (i1 != i0) IncrementCount(vertexCounts, i1); + if (i2 != i0 && i2 != i1) IncrementCount(vertexCounts, i2); + + if (i0 != i1) IncrementCount(pairCounts, VertexPairKey(i0, i1)); + if (i0 != i2) IncrementCount(pairCounts, VertexPairKey(i0, i2)); + if (i1 != i2) IncrementCount(pairCounts, VertexPairKey(i1, i2)); + if (i0 != i1 && i0 != i2 && i1 != i2) + IncrementCount(tripleCounts, new FaceVertexKey(i0, i1, i2)); + } + for (int i = 0; i < list.Count; i++) { - for (int j = i + 1; j < list.Count; j++) + int f = list[i]; + int i0 = triangles[f * 3], i1 = triangles[f * 3 + 1], i2 = triangles[f * 3 + 2]; + int sharedCount = vertexCounts[i0]; + + if (i1 != i0) + sharedCount += vertexCounts[i1] - pairCounts[VertexPairKey(i0, i1)]; + if (i2 != i0 && i2 != i1) { - if (!FacesShareVertex(faceVerts, list[i], list[j])) + sharedCount += vertexCounts[i2] - pairCounts[VertexPairKey(i0, i2)]; + if (i1 != i0) { - overlapping.Add(list[i]); - overlapping.Add(list[j]); + sharedCount -= pairCounts[VertexPairKey(i1, i2)]; + sharedCount += tripleCounts[new FaceVertexKey(i0, i1, i2)]; } } + + if (sharedCount < list.Count) + overlapping.Add(f); } } return overlapping; } + static long VertexPairKey(int v0, int v1) + { + return v0 < v1 ? ((long)v0 << 32) | (uint)v1 : ((long)v1 << 32) | (uint)v0; + } + + static void IncrementCount(Dictionary counts, TKey key) + { + counts.TryGetValue(key, out int count); + counts[key] = count + 1; + } + // ════════════════════════════════════════════════════════════ // Face adjacency graph // ════════════════════════════════════════════════════════════ diff --git a/Tests/Editor/SpatialPartitionerTests.cs b/Tests/Editor/SpatialPartitionerTests.cs new file mode 100644 index 00000000..1cb03e5f --- /dev/null +++ b/Tests/Editor/SpatialPartitionerTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class SpatialPartitionerTests + { + [Test] + public void PartitionShells_FacesSharingGridCellsAndVertex_DoNotOverlap() + { + var uv = BuildFullBoundsUvs(4); + var triangles = new[] + { + 0, 1, 2, + 0, 3, 4, + 0, 5, 6, + 0, 7, 8 + }; + + var result = PartitionSingleShell(uv, triangles); + + Assert.IsFalse(result.hasOverlap, + "Faces that only meet through a shared vertex must not be treated as UV overlap"); + } + + [Test] + public void PartitionShells_NonAdjacentFaceInSharedGridCells_DetectsOverlap() + { + var uv = BuildFullBoundsUvs(4); + var triangles = new[] + { + 0, 1, 2, + 0, 3, 4, + 0, 5, 6, + 9, 7, 8 + }; + + var result = PartitionSingleShell(uv, triangles); + + Assert.IsTrue(result.hasOverlap, + "A face sharing grid cells but no vertex must be treated as UV overlap"); + } + + static Vector2[] BuildFullBoundsUvs(int faceCount) + { + var uv = new Vector2[faceCount * 2 + 2]; + uv[0] = Vector2.zero; + for (int i = 0; i < faceCount; i++) + { + uv[i * 2 + 1] = Vector2.right; + uv[i * 2 + 2] = Vector2.up; + } + uv[9] = Vector2.zero; + return uv; + } + + static SpatialPartitioner.ShellPartitionResult PartitionSingleShell( + Vector2[] uv, int[] triangles) + { + var shell = new UvShell + { + shellId = 0, + boundsMin = Vector2.zero, + boundsMax = Vector2.one, + faceIndices = new List { 0, 1, 2, 3 } + }; + var vertices = new Vector3[uv.Length]; + + return SpatialPartitioner.PartitionShells( + new List { shell }, uv, triangles, vertices)[0]; + } + } +} diff --git a/Tests/Editor/SpatialPartitionerTests.cs.meta b/Tests/Editor/SpatialPartitionerTests.cs.meta new file mode 100644 index 00000000..d14e7b90 --- /dev/null +++ b/Tests/Editor/SpatialPartitionerTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7b74a1e5596540a38f8440ca7cf95d27 +timeCreated: 1786032000 From 390f7cb1e7d12af8a3246c3f801dac8d2813f952 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:53:22 +0200 Subject: [PATCH 29/76] fix(scene): bound SceneView hover raycasts (#187) The hover pick read mesh.vertices and ran shell extraction over every triangle on each ~33 ms mousemove. Check index metadata first and enforce a per-hover triangle budget, sized (100k) to still cover typical LOD0 game meshes, with a rate-limited warning so a skipped mesh is visible rather than silently unhoverable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index fbfb8edf..355a87bf 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -328,6 +328,12 @@ static UvtLog.Category[] BuildLogCategoryList() // ── Scene ── double sceneSpotLastRaycastTime; const double sceneSpotThrottleSec = 0.033; + // Per-hover triangle budget for the SceneView pick. Sized to cover a typical + // 20-50k tri LOD0 game mesh (and a few of them) so hover keeps working on + // real assets, while still bounding the per-mousemove cost. + const int sceneSpotTriangleBudget = 100000; + double sceneSpotLastBudgetWarnTime; + const double sceneSpotBudgetWarnIntervalSec = 5.0; // ════════════════════════════════════════════════════════════ // Lifecycle @@ -4852,11 +4858,24 @@ struct SceneHit public MeshEntry meshEntry; } + // Hover runs every ~33 ms, so the skip notice is rate-limited — but it must + // not be silent: a skipped mesh simply stops responding to SceneView hover. + void WarnHoverBudgetSkip(Mesh mesh) + { + double now = EditorApplication.timeSinceStartup; + if (now - sceneSpotLastBudgetWarnTime < sceneSpotBudgetWarnIntervalSec) return; + sceneSpotLastBudgetWarnTime = now; + UvtLog.Warn($"[SceneSpot] Hover pick skipped '{(mesh != null ? mesh.name : "")}' — " + + $"exceeds the {sceneSpotTriangleBudget} triangle budget per hover. " + + "Use the UV canvas or a lower preview LOD to inspect it."); + } + bool TryRaycastPreview(Ray ray, out SceneHit bestHit) { bestHit = default; bestHit.distance = float.PositiveInfinity; bool found = false; + int remainingTriangleBudget = sceneSpotTriangleBudget; foreach (var entry in ctx.ForLod(ctx.PreviewLod)) { @@ -4867,10 +4886,36 @@ bool TryRaycastPreview(Ray ray, out SceneHit bestHit) Bounds wb = TransformBounds(mesh.bounds, l2w); if (!wb.IntersectRay(ray, out float aabbDist) || aabbDist > bestHit.distance) continue; + // Inspect index metadata before reading mesh arrays: those properties make + // full managed copies and shell extraction is linear in the face count. + // Skip a mesh rather than partially testing it, which could report a false hit. + ulong meshIndexCount = 0; + for (int subMesh = 0; subMesh < mesh.subMeshCount; subMesh++) + { + ulong indexCount = mesh.GetIndexCount(subMesh); + if (indexCount > (ulong)remainingTriangleBudget * 3UL - meshIndexCount) + { + meshIndexCount = ulong.MaxValue; + break; + } + meshIndexCount += indexCount; + } + if (meshIndexCount == ulong.MaxValue) + { + WarnHoverBudgetSkip(mesh); + continue; + } + var v = mesh.vertices; var tri = canvas.GetTrianglesCached(mesh); var uv = canvas.RdUvCached(mesh, ctx.PreviewUvChannel); if (v == null || tri == null || uv == null) continue; + if (tri.Length / 3 > remainingTriangleBudget) + { + WarnHoverBudgetSkip(mesh); + continue; + } + remainingTriangleBudget -= tri.Length / 3; int[] faceToShell = ctx.UvPreviewShellCache.GetFaceToShell(mesh, ctx.PreviewUvChannel, uv, tri); for (int f = 0; f + 2 < tri.Length; f += 3) From 494b049e6c8414c0eeb98b187dd0af486587577c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:50:42 +0000 Subject: [PATCH 30/76] fix(lod): bound LOD indices parsed from object names (#142) Cleanup, LOD generation and Model Builder all parsed the trailing _LOD suffix with int.Parse and trusted the result: overflowing digits threw OverflowException and huge indices sized LOD arrays/loops unboundedly. Parse with int.TryParse and reject indices beyond the eight levels LODGroup supports. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/CleanupTool.cs | 28 ++++---- Editor/Tools/LodGenerationTool.cs | 23 ++++-- Editor/Tools/ModelBuilderTool.cs | 25 +++++-- Tests/Editor/CleanupToolTests.cs | 34 +++++++++ Tests/Editor/CleanupToolTests.cs.meta | 2 + Tests/Editor/LodGenerationToolTests.cs | 79 +++++++++++++++++++++ Tests/Editor/LodGenerationToolTests.cs.meta | 2 + 7 files changed, 170 insertions(+), 23 deletions(-) create mode 100644 Tests/Editor/CleanupToolTests.cs create mode 100644 Tests/Editor/CleanupToolTests.cs.meta create mode 100644 Tests/Editor/LodGenerationToolTests.cs create mode 100644 Tests/Editor/LodGenerationToolTests.cs.meta diff --git a/Editor/Tools/CleanupTool.cs b/Editor/Tools/CleanupTool.cs index 28cf33de..9ada610f 100644 --- a/Editor/Tools/CleanupTool.cs +++ b/Editor/Tools/CleanupTool.cs @@ -11,6 +11,8 @@ namespace SashaRX.UnityMeshLab { public class CleanupTool : IUvTool { + const int MaxLodLevels = 8; + UvToolContext ctx; UvCanvasView canvas; Action requestRepaint; @@ -1228,12 +1230,7 @@ void ScanScene() var child = root.GetChild(i); if (colSet.Contains(child.gameObject)) continue; - var match = System.Text.RegularExpressions.Regex.Match( - child.name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (!match.Success) continue; - - int lodIdx = int.Parse(match.Groups[1].Value); + if (!TryParseLodIndex(child.name, out int lodIdx)) continue; var r = child.GetComponent(); if (r == null) continue; @@ -1500,12 +1497,7 @@ void RebuildLodGroupFromHierarchy() var child = root.GetChild(i); if (colSet.Contains(child.gameObject)) continue; - var match = System.Text.RegularExpressions.Regex.Match( - child.name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (!match.Success) continue; - - int lodIdx = int.Parse(match.Groups[1].Value); + if (!TryParseLodIndex(child.name, out int lodIdx)) continue; var r = child.GetComponent(); if (r == null) continue; @@ -1536,6 +1528,18 @@ void RebuildLodGroupFromHierarchy() UvtLog.Info($"Rebuilt LODGroup with {maxLod} LOD level(s)."); } + static bool TryParseLodIndex(string name, out int lodIndex) + { + lodIndex = 0; + var match = System.Text.RegularExpressions.Regex.Match( + name, @"_LOD(\d+)$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + return match.Success + && int.TryParse(match.Groups[1].Value, out lodIndex) + && lodIndex < MaxLodLevels; + } + // ═══════════════════════════════════════════════════════════════ // Section 4: Mesh // ═══════════════════════════════════════════════════════════════ diff --git a/Editor/Tools/LodGenerationTool.cs b/Editor/Tools/LodGenerationTool.cs index 1ae9252f..0a094d10 100644 --- a/Editor/Tools/LodGenerationTool.cs +++ b/Editor/Tools/LodGenerationTool.cs @@ -527,6 +527,15 @@ public void OnSceneGUI(SceneView sv) { } // ── Auto-detect LOD siblings ── + // LODGroup supports at most eight levels; reject name-derived indices outside that range. + const int MaxSupportedLodIndex = 7; + + static bool TryGetSupportedLodIndex(Match match, out int lodIndex) + { + return int.TryParse(match.Groups[2].Value, out lodIndex) + && lodIndex <= MaxSupportedLodIndex; + } + /// /// Given a GameObject whose name ends with a LOD suffix (e.g. Gazebo_LOD0), /// find all sibling GameObjects under the same parent that share the same @@ -541,6 +550,10 @@ public void OnSceneGUI(SceneView sv) { } var m = Regex.Match(go.name, @"^(.+?)([_\-\s]*)LOD(\d+)$", RegexOptions.IgnoreCase); if (m.Success) { + if (!int.TryParse(m.Groups[3].Value, out int selectedLodIndex) + || selectedLodIndex > MaxSupportedLodIndex) + return null; + // Selected object has LOD suffix — search siblings string baseName = m.Groups[1].Value; var parent = go.transform.parent; @@ -551,8 +564,10 @@ public void OnSceneGUI(SceneView sv) { } { var child = parent.GetChild(i).gameObject; var cm = Regex.Match(child.name, @"^(.+?)[_\-\s]*LOD(\d+)$", RegexOptions.IgnoreCase); - if (cm.Success && string.Equals(cm.Groups[1].Value, baseName, System.StringComparison.OrdinalIgnoreCase)) - results.Add((child, int.Parse(cm.Groups[2].Value))); + if (cm.Success + && string.Equals(cm.Groups[1].Value, baseName, System.StringComparison.OrdinalIgnoreCase) + && TryGetSupportedLodIndex(cm, out int lodIndex)) + results.Add((child, lodIndex)); } results.Sort((a, b) => a.Item2.CompareTo(b.Item2)); @@ -566,8 +581,8 @@ public void OnSceneGUI(SceneView sv) { } { var child = go.transform.GetChild(i).gameObject; var cm = Regex.Match(child.name, @"^(.+?)[_\-\s]*LOD(\d+)$", RegexOptions.IgnoreCase); - if (cm.Success) - childResults.Add((child, int.Parse(cm.Groups[2].Value))); + if (cm.Success && TryGetSupportedLodIndex(cm, out int lodIndex)) + childResults.Add((child, lodIndex)); } if (childResults.Count > 0) diff --git a/Editor/Tools/ModelBuilderTool.cs b/Editor/Tools/ModelBuilderTool.cs index 20ec5ebd..34ac9ff2 100644 --- a/Editor/Tools/ModelBuilderTool.cs +++ b/Editor/Tools/ModelBuilderTool.cs @@ -10,6 +10,8 @@ namespace SashaRX.UnityMeshLab { public class ModelBuilderTool : IUvTool { + const int MaxLodLevels = 8; + UvToolContext ctx; UvCanvasView canvas; System.Action requestRepaint; @@ -365,7 +367,21 @@ static int GetLodIndexFromName(string name) var match = System.Text.RegularExpressions.Regex.Match( name, @"_LOD(\d+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - return match.Success ? int.Parse(match.Groups[1].Value) : -1; + return match.Success && int.TryParse(match.Groups[1].Value, out int lodIdx) ? lodIdx : -1; + } + + // LODGroup supports at most eight levels; reject name-derived indices + // outside that range (also guards int.Parse against overflowing digits). + static bool TryParseLodIndex(string name, out int lodIndex) + { + lodIndex = 0; + var match = System.Text.RegularExpressions.Regex.Match( + name, @"_LOD(\d+)$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + return match.Success + && int.TryParse(match.Groups[1].Value, out lodIndex) + && lodIndex < MaxLodLevels; } void DrawEditableName(GameObject go, string suffix, int indent) @@ -785,12 +801,7 @@ void RebuildLodGroupFromNames() if (r == null || r.transform == root) continue; if (colSet.Contains(r.gameObject)) continue; - var match = System.Text.RegularExpressions.Regex.Match( - r.gameObject.name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (!match.Success) continue; - - int lodIdx = int.Parse(match.Groups[1].Value); + if (!TryParseLodIndex(r.gameObject.name, out int lodIdx)) continue; if (!lodChildren.ContainsKey(lodIdx)) lodChildren[lodIdx] = new List(); diff --git a/Tests/Editor/CleanupToolTests.cs b/Tests/Editor/CleanupToolTests.cs new file mode 100644 index 00000000..4dc0f26f --- /dev/null +++ b/Tests/Editor/CleanupToolTests.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using NUnit.Framework; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class CleanupToolTests + { + static bool TryParseLodIndex(string name, out int lodIndex) + { + var method = typeof(CleanupTool).GetMethod( + "TryParseLodIndex", BindingFlags.NonPublic | BindingFlags.Static); + var arguments = new object[] { name, 0 }; + bool result = (bool)method.Invoke(null, arguments); + lodIndex = (int)arguments[1]; + return result; + } + + [TestCase("Part_LOD0", 0)] + [TestCase("Part_lod7", 7)] + public void TryParseLodIndex_ValidIndex_ReturnsTrue(string name, int expected) + { + Assert.IsTrue(TryParseLodIndex(name, out int actual)); + Assert.AreEqual(expected, actual); + } + + [TestCase("Part_LOD8")] + [TestCase("Part_LOD100000000")] + [TestCase("Part_LOD999999999999999999999999999999")] + public void TryParseLodIndex_UnsupportedOrOverflowingIndex_ReturnsFalse(string name) + { + Assert.IsFalse(TryParseLodIndex(name, out _)); + } + } +} diff --git a/Tests/Editor/CleanupToolTests.cs.meta b/Tests/Editor/CleanupToolTests.cs.meta new file mode 100644 index 00000000..f4c54b64 --- /dev/null +++ b/Tests/Editor/CleanupToolTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52ddde54ce0d489c85dad1c8f0f3f535 diff --git a/Tests/Editor/LodGenerationToolTests.cs b/Tests/Editor/LodGenerationToolTests.cs new file mode 100644 index 00000000..f3e5c77f --- /dev/null +++ b/Tests/Editor/LodGenerationToolTests.cs @@ -0,0 +1,79 @@ +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class LodGenerationToolTests + { + GameObject root; + + [SetUp] + public void SetUp() + { + root = new GameObject("Root"); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(root); + } + + [TestCase("Asset_LOD999999999999999999999")] + [TestCase("Asset_LOD8")] + [TestCase("Asset_LOD100000000")] + public void FindLodSiblings_RejectsUnsupportedIndices(string name) + { + var selected = CreateChild(name); + + Assert.That(FindLodSiblings(selected), Is.Null); + } + + [Test] + public void FindLodSiblings_IgnoresUnsupportedSiblingIndices() + { + var selected = CreateChild("Asset_LOD0"); + CreateChild("Asset_LOD1"); + CreateChild("Asset_LOD999999999999999999999"); + CreateChild("Asset_LOD100000000"); + + var siblings = FindLodSiblings(selected); + + Assert.That(siblings, Has.Count.EqualTo(2)); + Assert.That(siblings[0].lodIndex, Is.EqualTo(0)); + Assert.That(siblings[1].lodIndex, Is.EqualTo(1)); + } + + GameObject CreateChild(string name) + { + var child = new GameObject(name); + child.transform.SetParent(root.transform); + return child; + } + + // FindLodSiblings is internal to the editor assembly, so the test + // assembly reaches it through reflection like the other suites here. + static List<(GameObject go, int lodIndex)> FindLodSiblings(GameObject go) + { + var method = typeof(LodGenerationTool).GetMethod( + "FindLodSiblings", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, "LodGenerationTool should expose FindLodSiblings as a static helper"); + + object result = method.Invoke(null, new object[] { go }); + if (result == null) return null; + + var siblings = new List<(GameObject go, int lodIndex)>(); + foreach (object item in (IEnumerable)result) + { + var type = item.GetType(); + siblings.Add(( + (GameObject)type.GetField("Item1").GetValue(item), + (int)type.GetField("Item2").GetValue(item))); + } + return siblings; + } + } +} diff --git a/Tests/Editor/LodGenerationToolTests.cs.meta b/Tests/Editor/LodGenerationToolTests.cs.meta new file mode 100644 index 00000000..10440f8c --- /dev/null +++ b/Tests/Editor/LodGenerationToolTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e6b92a1ab7714bbf8390e69cffb1429d From 9d11d5620542c83a17c5f65a622edcaaab268363 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:51:31 +0000 Subject: [PATCH 31/76] fix(lod): normalize LOD0 transition before generating levels (#179) An LODGroup auto-created from unlabelled renderers uses a 0.01 transition height so its single level is not culled early. Appending generated LODs on top of that produced near-zero thresholds (0.005, 0.0025, ...). Restore the regular 0.5 LOD0 transition first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LodGenerationTool.cs | 14 ++++++++ Tests/Editor/LodGenerationToolTests.cs | 45 ++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/Editor/Tools/LodGenerationTool.cs b/Editor/Tools/LodGenerationTool.cs index 0a094d10..5c631da2 100644 --- a/Editor/Tools/LodGenerationTool.cs +++ b/Editor/Tools/LodGenerationTool.cs @@ -344,6 +344,11 @@ void ExecGenerateLods(int startLod) var lods = ctx.LodGroup.GetLODs(); var newLods = new List(lods); + // A group created from unlabelled renderers uses a low value so its only + // LOD is not culled early. Restore the normal LOD0 transition before + // appending generated levels; otherwise they start at 0.005 and below. + NormalizeSingleLodTransitionForGeneration(newLods, startLod); + UvProgress.Begin($"Generate LODs ({generateLodCount} levels)", cancelable: true); try { @@ -614,6 +619,15 @@ internal static LODGroup CreateLodGroupStatic(List<(GameObject go, int lodIndex) return lodGroup; } + internal static void NormalizeSingleLodTransitionForGeneration(List lods, int startLod) + { + if (startLod != 1 || lods.Count != 1 || + !Mathf.Approximately(lods[0].screenRelativeTransitionHeight, 0.01f)) + return; + + lods[0] = new LOD(0.5f, lods[0].renderers); + } + internal static LODGroup CreateLodGroupFromRenderers(GameObject root) { var renderers = root.GetComponentsInChildren(); diff --git a/Tests/Editor/LodGenerationToolTests.cs b/Tests/Editor/LodGenerationToolTests.cs index f3e5c77f..1eed6e5d 100644 --- a/Tests/Editor/LodGenerationToolTests.cs +++ b/Tests/Editor/LodGenerationToolTests.cs @@ -47,6 +47,36 @@ public void FindLodSiblings_IgnoresUnsupportedSiblingIndices() Assert.That(siblings[1].lodIndex, Is.EqualTo(1)); } + [Test] + public void NormalizeSingleLodTransitionForGeneration_AutoCreatedGroup_RestoresLod0Transition() + { + var lods = new List { new LOD(0.01f, new Renderer[0]) }; + + NormalizeSingleLodTransitionForGeneration(lods, 1); + + Assert.AreEqual(0.5f, lods[0].screenRelativeTransitionHeight); + } + + [Test] + public void NormalizeSingleLodTransitionForGeneration_ExistingTransition_PreservesValue() + { + var lods = new List { new LOD(0.25f, new Renderer[0]) }; + + NormalizeSingleLodTransitionForGeneration(lods, 1); + + Assert.AreEqual(0.25f, lods[0].screenRelativeTransitionHeight); + } + + [Test] + public void NormalizeSingleLodTransitionForGeneration_NotAppendingAfterLod0_PreservesValue() + { + var lods = new List { new LOD(0.01f, new Renderer[0]) }; + + NormalizeSingleLodTransitionForGeneration(lods, 2); + + Assert.AreEqual(0.01f, lods[0].screenRelativeTransitionHeight); + } + GameObject CreateChild(string name) { var child = new GameObject(name); @@ -54,8 +84,19 @@ GameObject CreateChild(string name) return child; } - // FindLodSiblings is internal to the editor assembly, so the test - // assembly reaches it through reflection like the other suites here. + // The helpers below are internal to the editor assembly, so the test + // assembly reaches them through reflection like the other suites here. + static void NormalizeSingleLodTransitionForGeneration(List lods, int startLod) + { + var method = typeof(LodGenerationTool).GetMethod( + "NormalizeSingleLodTransitionForGeneration", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, + "LodGenerationTool should expose the LOD0 transition normalization as a static helper"); + + method.Invoke(null, new object[] { lods, startLod }); + } + static List<(GameObject go, int lodIndex)> FindLodSiblings(GameObject go) { var method = typeof(LodGenerationTool).GetMethod( From d57c3c6a79aa97d65e2b35ba0ee242db642b8ee2 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:52:39 +0200 Subject: [PATCH 32/76] fix(transfer): isolate cross-LOD hints per mesh group (#181) Shell indices are local to a source mesh, but overlap/match hints were accumulated in shared lists, so hints from one mesh group could steer the transfer of an unrelated mesh with the same numeric shell index. Key the hint state by source entry + mesh group. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 13 +++++++++ Editor/Tools/LightmapTransferTool.cs | 43 +++++++++++++++++++--------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index a1003274..34de2e02 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -13,6 +13,19 @@ - **Ожидание/проверка:** штатные разрешения не меняются; переполняющиеся значения завершают repack с ошибкой до native pack. +## Эксперимент 2026-08-06 — Изоляция cross-LOD hints по mesh group + +- **Проблема:** индексы source shell локальны для mesh, но overlap/match hints + накапливались в общих списках и могли влиять на несвязанный mesh с совпавшим + числовым индексом shell. +- **Изменение:** состояние hints разделено по паре source `MeshEntry` + + `meshGroupKey`; между LOD передаются только hints той же пары. +- **Сохранение поведения:** внутри одной LOD-цепочки приоритет issues → hint → + 3D distance не меняется; очищение состояния перед новым transfer/auto-tune + запуском сохранено. +- **Проверка:** требуется ручной Unity-прогон на нескольких mesh groups с + совпадающими локальными индексами shell и сравнение UV2 на всех LOD. + ## Правила экспериментов 1. Один PR = одно изменение. Не наслаивать фиксы. diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 355a87bf..d789a782 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -308,10 +308,18 @@ static UvtLog.Category[] BuildLogCategoryList() // ── Transfer cache ── Dictionary shellTransformCache = new Dictionary(); - List accumulatedOverlapHints = - new List(); - List accumulatedMatchHints = - new List(); + sealed class CrossLodHintState + { + public readonly List overlapHints = + new List(); + public readonly List matchHints = + new List(); + } + + // Shell indices are local to a source mesh. Keep cross-LOD hints isolated + // to the source/mesh-group pair that produced them. + readonly Dictionary<(MeshEntry source, string meshGroupKey), CrossLodHintState> crossLodHints = + new Dictionary<(MeshEntry, string), CrossLodHintState>(); // ── Preview ── // Three mutually-exclusive preview modes. Only one should be active at a time. @@ -1933,7 +1941,7 @@ async Task ExecFullPipelineCoreImpl(bool useAsync) kv.Key.shellTransferResult = null; } ctx.ClearAllCaches(); - accumulatedOverlapHints.Clear(); + crossLodHints.Clear(); shellTransformCache.Clear(); ctx.HasRepack = false; ctx.HasTransfer = false; @@ -2251,8 +2259,7 @@ async Task ExecTransferAllImpl(bool useAsync) return; } - accumulatedOverlapHints.Clear(); - accumulatedMatchHints.Clear(); + crossLodHints.Clear(); int processed = 0; for (int li = 0; li < ctx.LodCount; li++) { @@ -2321,6 +2328,14 @@ async Task ExecTransferLodImpl(int tLod, bool useAsync) Mesh tgtMesh = tgt.originalMesh; if (srcMesh == null || tgtMesh == null) continue; + string meshGroupKey = tgt.meshGroupKey ?? tgt.renderer.name; + var hintKey = (source: srcEntry, meshGroupKey: meshGroupKey); + if (!crossLodHints.TryGetValue(hintKey, out var hintState)) + { + hintState = new CrossLodHintState(); + crossLodHints.Add(hintKey, hintState); + } + int srcId = srcMesh.GetInstanceID(); if (!shellTransformCache.TryGetValue(srcId, out var srcInfos)) { @@ -2332,26 +2347,26 @@ async Task ExecTransferLodImpl(int tLod, bool useAsync) UvProgress.Report(-1f, $"Transfer LOD{tLod} ← '{tgt.renderer.name}'"); var tr = useAsync ? await GroupedShellTransfer.TransferAsync(tgtMesh, srcMesh, - accumulatedOverlapHints.Count > 0 ? accumulatedOverlapHints : null, - accumulatedMatchHints.Count > 0 ? accumulatedMatchHints : null, + hintState.overlapHints.Count > 0 ? hintState.overlapHints : null, + hintState.matchHints.Count > 0 ? hintState.matchHints : null, srcEntry.repackedAtlasWidth > 0 ? (int)srcEntry.repackedAtlasWidth : 0, srcEntry.repackedAtlasHeight > 0 ? (int)srcEntry.repackedAtlasHeight : 0) : GroupedShellTransfer.Transfer(tgtMesh, srcMesh, - accumulatedOverlapHints.Count > 0 ? accumulatedOverlapHints : null, - accumulatedMatchHints.Count > 0 ? accumulatedMatchHints : null, + hintState.overlapHints.Count > 0 ? hintState.overlapHints : null, + hintState.matchHints.Count > 0 ? hintState.matchHints : null, srcEntry.repackedAtlasWidth > 0 ? (int)srcEntry.repackedAtlasWidth : 0, srcEntry.repackedAtlasHeight > 0 ? (int)srcEntry.repackedAtlasHeight : 0); if (tr.uv2 == null) { UvtLog.Warn($"[Transfer] Failed for '{tgt.renderer.name}'"); continue; } // Accumulate overlap hints for subsequent LODs if (tr.overlapHints != null && tr.overlapHints.Count > 0) - accumulatedOverlapHints.AddRange(tr.overlapHints); + hintState.overlapHints.AddRange(tr.overlapHints); // Replace match hints with this LOD's matches (latest LOD drives // next LOD's hint-guided matching; stale hints from older LODs // could conflict with changing geometry) - accumulatedMatchHints.Clear(); + hintState.matchHints.Clear(); if (tr.matchHints != null && tr.matchHints.Count > 0) - accumulatedMatchHints.AddRange(tr.matchHints); + hintState.matchHints.AddRange(tr.matchHints); // Build output mesh with UV2 applied var om = UnityEngine.Object.Instantiate(tgtMesh); From dab2c98f1d6c8f515ffe6a54a49ac28d18a83b30 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:31:55 +0200 Subject: [PATCH 33/76] fix(collision): bound V-HACD recursion depth (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Max Recursion slider allowed 25 and passed the value straight to V-HACD, where intermediate hull count grows exponentially and the merge phase is O(n²). Clamp to 10 in the builder, cap the slider, and re-clamp in the native bridge so the ABI cannot bypass the budget. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/CollisionMeshBuilder.cs | 8 +++++++- Editor/Tools/CollisionMeshTool.cs | 4 ++-- Native~/src/collision.cpp | 9 +++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Editor/CollisionMeshBuilder.cs b/Editor/CollisionMeshBuilder.cs index 6c311d9d..561efd55 100644 --- a/Editor/CollisionMeshBuilder.cs +++ b/Editor/CollisionMeshBuilder.cs @@ -10,6 +10,11 @@ namespace SashaRX.UnityMeshLab { public static class CollisionMeshBuilder { + // V-HACD's intermediate hull count grows exponentially with recursion depth and + // its merge phase allocates O(n²) work. Keep the historical V-HACD default as + // a hard work-budget boundary; the native bridge enforces the same limit. + public const int MaxConvexRecursionDepth = 10; + // ── Simplified mode ── public struct SimplifiedResult @@ -149,6 +154,7 @@ public static ConvexDecompResult BuildConvexDecomposition(Mesh sourceMesh, Conve // Clamp maxVertsPerHull to PhysX limit int maxVPH = Mathf.Clamp(settings.maxVertsPerHull, 8, 255); + int maxRecursionDepth = Mathf.Clamp(settings.maxRecursionDepth, 1, MaxConvexRecursionDepth); IntPtr ctx = IntPtr.Zero; try @@ -160,7 +166,7 @@ public static ConvexDecompResult BuildConvexDecomposition(Mesh sourceMesh, Conve settings.resolution, maxVPH, settings.minVolumePerHull, - settings.maxRecursionDepth, + maxRecursionDepth, settings.shrinkWrap ? 1 : 0, settings.fillMode, settings.minEdgeLength, diff --git a/Editor/Tools/CollisionMeshTool.cs b/Editor/Tools/CollisionMeshTool.cs index 28b6a4f3..8e582ee2 100644 --- a/Editor/Tools/CollisionMeshTool.cs +++ b/Editor/Tools/CollisionMeshTool.cs @@ -229,8 +229,8 @@ void DrawConvexSettings() new GUIContent("Fill Mode", "How to determine inside vs outside.\n\nFlood Fill: default, works for closed meshes.\nSurface Only: hollow result, for thin shells.\nRaycast Fill: better for meshes with holes."), convexFillMode, fillModeNames); convexMaxRecursionDepth = EditorGUILayout.IntSlider( - new GUIContent("Max Recursion", "Maximum depth of recursive splitting. Higher = finer decomposition, slower. Default: 10."), - convexMaxRecursionDepth, 1, 25); + new GUIContent("Max Recursion", "Maximum depth of recursive splitting. Higher = finer decomposition, slower. Capped at 10 to bound memory and CPU use."), + convexMaxRecursionDepth, 1, CollisionMeshBuilder.MaxConvexRecursionDepth); convexMinEdgeLength = EditorGUILayout.IntSlider( new GUIContent("Min Edge Length", "Stop recursing when voxel patch edge is below this length. Lower = more detail. Default: 2."), convexMinEdgeLength, 1, 8); diff --git a/Native~/src/collision.cpp b/Native~/src/collision.cpp index c017959a..c42d40c6 100644 --- a/Native~/src/collision.cpp +++ b/Native~/src/collision.cpp @@ -55,6 +55,15 @@ EXPORT void* ConvexDecomp_Compute( if (!vertices || !indices || vertexCount <= 0 || indexCount < 3) return nullptr; + // Recursive splitting can create 2^depth intermediate hulls, followed by an + // O(n²) merge-cost allocation. Treat the native ABI as a trust boundary so + // callers cannot bypass the editor-side work-budget limit. + constexpr int kMaxSafeRecursionDepth = 10; + if (maxRecursionDepth < 1) + maxRecursionDepth = 1; + else if (maxRecursionDepth > kMaxSafeRecursionDepth) + maxRecursionDepth = kMaxSafeRecursionDepth; + VHACD::IVHACD* vhacd = VHACD::CreateVHACD(); if (!vhacd) return nullptr; From e24de0061b86e283b565b61958a58ad2cf1df9ff Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:42:54 +0200 Subject: [PATCH 34/76] fix(collision): validate V-HACD triangle indices (#152) ConvexDecomp_Compute passed caller-supplied indices to V-HACD, which reinterprets them as unsigned and dereferences without bounds checks. Reject non-triangulated index counts and out-of-range indices at the ABI boundary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Native~/src/collision.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Native~/src/collision.cpp b/Native~/src/collision.cpp index c42d40c6..e1a4346b 100644 --- a/Native~/src/collision.cpp +++ b/Native~/src/collision.cpp @@ -52,9 +52,17 @@ EXPORT void* ConvexDecomp_Compute( int minEdgeLength, int findBestPlane) { - if (!vertices || !indices || vertexCount <= 0 || indexCount < 3) + if (!vertices || !indices || vertexCount <= 0 || indexCount < 3 || indexCount % 3 != 0) return nullptr; + // V-HACD treats indices as unsigned and dereferences them without bounds checks. + // Validate at the native API boundary so malformed meshes cannot cause OOB reads. + for (int i = 0; i < indexCount; i++) + { + if (indices[i] < 0 || indices[i] >= vertexCount) + return nullptr; + } + // Recursive splitting can create 2^depth intermediate hulls, followed by an // O(n²) merge-cost allocation. Treat the native ABI as a trust boundary so // callers cannot bypass the editor-side work-budget limit. From 504f84d648da461e57d04a233e7cb4e232f7301a Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:44:38 +0200 Subject: [PATCH 35/76] fix(fbx): avoid overflow in FBX backup path hashes (#162) Math.Abs throws OverflowException when GetHashCode() returns int.MinValue, aborting FBX export while building the backup file name. Format the hash as an unchecked uint instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index d789a782..1113e68f 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -2910,7 +2910,7 @@ public void ExportVertexColorsToFbx() // Hash the full path so two FBX files with the same filename // (e.g. Assets/A/Chair.fbx and Assets/B/Chair.fbx) get distinct // backup names and never overwrite each other. - string pathHash = Math.Abs(fullPath.GetHashCode()).ToString("X8"); + string pathHash = unchecked((uint)fullPath.GetHashCode()).ToString("X8"); string metaBak = System.IO.Path.Combine( System.IO.Path.GetTempPath(), System.IO.Path.GetFileName(fullPath) + "." + pathHash + ".meta.bak"); @@ -3175,7 +3175,7 @@ void ExportFbx(bool overwriteSource) // backup names and never overwrite each other. string fullSourcePath = System.IO.Path.GetFullPath(sourceFbxPath); string fbxBakName = System.IO.Path.GetFileName(fullSourcePath) + "." + - Math.Abs(fullSourcePath.GetHashCode()).ToString("X8"); + unchecked((uint)fullSourcePath.GetHashCode()).ToString("X8"); if (overwriteSource) { if (!EditorUtility.DisplayDialog("Overwrite Source FBX", From fbd797d7094e87b95c844b6911751c2e92b01a1a Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:45:14 +0200 Subject: [PATCH 36/76] fix(transfer): expose Apply UV2 after source-only repack (#170) The Apply/Reset actions were nested inside the `if (ctx.HasTransfer)` block, so a source-only repack (HasRepack = true, HasTransfer = false) had no way to write its result even though GetResultMesh already handles that case. Gate them on repack-or-transfer instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 8 ++++++++ Editor/Tools/LightmapTransferTool.cs | 21 ++++++++++----------- Tests/Editor/XatlasRepackGroupMergeTests.cs | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 34de2e02..72ccb147 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -396,3 +396,11 @@ - EditMode red/green: `TransferTargetDetection_IgnoresSourceOnlySelection`. - EditMode red/green: `Uv2PixelMargin_ScalesFromResolvedAtlasSize`. - Full model benchmark (Carousel/Playground/WateringCan) в этом checkout не прогнан: тестовые FBX/`BenchmarkReports/` отсутствуют в репозитории. Нужен ручной Unity прогон на suite для финального сравнения `repackMs`, `density spread`, `overlapShellPairs`, `invertedCount`, `texelDensityBadCount`. + +## Эксперимент 2026-08-06 — Apply UV2 после source-only repack + +**Проблема:** оптимизация source-only pipeline корректно пропускала transfer, но оставляла `HasTransfer = false`. Из-за общего UI-gate результат repack нельзя было применить к FBX. + +**Изменение:** Quality Report и Validation Overlay по-прежнему требуют завершённого transfer, а Apply/Reset actions теперь доступны после repack или transfer. Transfer и auto-tune для source-only workflow остаются пропущенными. + +**Проверка:** EditMode-тест `ApplyUv2_IsAvailable_AfterSourceOnlyRepack` фиксирует доступность Apply при `HasRepack = true` и `HasTransfer = false`. diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 1113e68f..693a31e0 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -52,6 +52,11 @@ static bool HasIncludedTransferTargets(IEnumerable entries, int sourc return false; } + static bool CanApplyUv2(bool hasRepack, bool hasTransfer) + { + return hasRepack || hasTransfer; + } + List GetRepackSourceMeshes() { return ctx.ForLod(ctx.SourceLodIndex) @@ -1390,19 +1395,13 @@ void DrawTransfer() EditorStyles.miniLabel); EditorGUI.indentLevel--; } + } + if (CanApplyUv2(ctx.HasRepack, ctx.HasTransfer)) + { EditorGUILayout.Space(6); - // Post-transfer actions — what you do immediately after a - // successful UV2 transfer (apply to FBX / reset). - // - // FBX export ("Overwrite FBX" / "Export New FBX" / - // "Backup from main") and "Save Mesh Assets" live in the - // sidebar footer for any tab; duplicating them here was - // confusing redundancy. - // - // "Generate LODs" was rendered here too, but LOD generation - // is the job of the dedicated LOD Gen tab — keeping it on - // Transfer made the tab feel scope-creepy. + // The source LOD can be applied immediately after repack, + // even when there are no included target LODs to transfer. H("Apply UV2"); ColorBtn(new Color(.3f,.85f,.4f), "Apply UV2 to FBX", 26, ApplyUv2ToFbx); EditorGUILayout.Space(2); diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs index 1ec47ea7..4ccaa92e 100644 --- a/Tests/Editor/XatlasRepackGroupMergeTests.cs +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -325,5 +325,19 @@ public void TransferTargetDetection_IgnoresSourceOnlySelection() Object.DestroyImmediate(e.originalMesh); } } + + [Test] + public void ApplyUv2_IsAvailable_AfterSourceOnlyRepack() + { + var method = typeof(LightmapTransferTool).GetMethod( + "CanApplyUv2", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "LightmapTransferTool should expose the Apply UV2 availability rule as a testable helper"); + + Assert.IsTrue((bool)method.Invoke(null, new object[] { true, false }), + "A source-only repack must remain applyable when transfer is skipped."); + Assert.IsTrue((bool)method.Invoke(null, new object[] { false, true })); + Assert.IsFalse((bool)method.Invoke(null, new object[] { false, false })); + } } } From 13d50b6f848c54c9297671322be97cac26252d91 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:45:18 +0200 Subject: [PATCH 37/76] fix(repack): keep post-pack density correction inside packed charts (#171) Post-pack shrink anchored the whole UV0 shell on one shared centroid, so when xatlas split that shell into several disjoint charts the correction translated charts through neighbouring packed regions. Shrink each packed chart around its own centroid and skip shells with orphaned chart IDs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 10 ++++ Editor/Tools/LightmapTransferTool.cs | 4 +- Editor/XatlasRepack.cs | 61 +++++++++++---------- Tests/Editor/XatlasRepackGroupMergeTests.cs | 41 ++++++++++++++ 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 72ccb147..61506bb2 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -26,6 +26,16 @@ - **Проверка:** требуется ручной Unity-прогон на нескольких mesh groups с совпадающими локальными индексами shell и сравнение UV2 на всех LOD. +## Эксперимент 2026-08-06 — Безопасная post-pack коррекция split charts + +- **Проблема:** shrink исходного UV shell вокруг общего центроида сдвигал его + разнесённые xatlas charts через занятые области атласа. +- **Изменение:** плотность и коэффициент по-прежнему считаются на исходный shell, + но каждый фактический xatlas chart сжимается вокруг собственного центроида. + Shell с вершинами без надёжного chart ID остаётся без изменений. +- **Ожидание:** chart после коррекции остаётся внутри собственного packed AABB; + существующее выравнивание плотности и shrink-only поведение сохраняются. + ## Правила экспериментов 1. Один PR = одно изменение. Не наслаивать фиксы. diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 693a31e0..44ae7fb5 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -1246,8 +1246,8 @@ void DrawRepackAdvancedControls() { ctx.PostPackDensityCorrection = EditorGUILayout.ToggleLeft( new GUIContent("Post-pack density correction (experimental)", - "After pack, shrink over-dense shells toward the median around their UV2 centroid. " - + "Compensates xatlas's per-chart ceil(extents) stretch. Shrink-only; leaves gaps."), + "After pack, shrink over-dense shells toward the median around each packed chart's UV2 centroid. " + + "Each chart stays inside its packed bounds. Shrink-only; leaves gaps."), ctx.PostPackDensityCorrection); ctx.XatlasTexelsPerUnit = EditorGUILayout.FloatField( new GUIContent("Texels per UV unit (manual)", diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index f1c220ed..d98f8c7c 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -474,17 +474,18 @@ static void LogPostPackDensity( /// 8362) amplifies thin/anisotropic shells more than fat ones, breaking /// the uniform density we set up in TexelDensityNormalizer. This pass /// measures per-shell au2/a3 and shrinks shells whose density is above - /// the median toward it, keeping each shell anchored on its UV2 - /// centroid. Shrink only — never expand — so the layout stays valid - /// (shells can't collide into neighbours). The atlas ends up with some + /// the median toward it. Each packed xatlas chart is shrunk around its + /// own UV2 centroid, so it remains inside its packed bounds. The atlas + /// ends up with some /// gaps where shrunk shells used to be; this trades coverage for density /// uniformity, which is the correct trade for lightmap bake quality. /// static int ApplyPostPackDensityCorrection( Vector2[] uv2, int[] tris, Vector3[] positions, - List shells, string meshLabel) + List shells, uint[] vertexChartIds, string meshLabel) { - if (uv2 == null || tris == null || positions == null || shells == null) return 0; + if (uv2 == null || tris == null || positions == null || shells == null || + vertexChartIds == null || vertexChartIds.Length < uv2.Length) return 0; int uvLen = uv2.Length; int posLen = positions.Length; int n = shells.Count; @@ -542,38 +543,42 @@ static int ApplyPostPackDensityCorrection( if (!IsFiniteD(scaleD) || scaleD <= 0.0) continue; if (scaleD >= 0.999) continue; // basically no-op float scale = (float)scaleD; - if (scale < appliedScaleMin) appliedScaleMin = scale; - if (scale > appliedScaleMax) appliedScaleMax = scale; - var shell = shells[si]; if (shell.vertexIndices == null || shell.vertexIndices.Count == 0) continue; - // UV2 centroid (uniform shrink leaves the centroid fixed → the - // shell stays where xatlas put it; only the bbox contracts - // inward, so neighbours stay outside the shrunken bbox). - Vector2 c = Vector2.zero; - int cn = 0; + // A shell may contain several separately packed xatlas charts. + // Contract each chart independently; a shell-wide centroid + // could translate separated charts through occupied space. + var chartVertices = new Dictionary>(); + bool hasOrphan = false; foreach (int v in shell.vertexIndices) { int idx = v; if ((uint)idx >= (uint)uvLen) continue; - c.x += uv2[idx].x; - c.y += uv2[idx].y; - cn++; + uint chartId = vertexChartIds[idx]; + if (chartId == ORPHAN_CHART) { hasOrphan = true; break; } + if (!chartVertices.TryGetValue(chartId, out var vertices)) + { + vertices = new List(); + chartVertices.Add(chartId, vertices); + } + vertices.Add(idx); } - if (cn == 0) continue; - c.x /= cn; - c.y /= cn; + // Without a chart ID there is no packed region whose bounds we + // can preserve, so leave the complete shell unchanged. + if (hasOrphan || chartVertices.Count == 0) continue; - foreach (int v in shell.vertexIndices) + foreach (var pair in chartVertices) { - int idx = v; - if ((uint)idx >= (uint)uvLen) continue; - Vector2 uv = uv2[idx]; - uv2[idx] = new Vector2( - c.x + (uv.x - c.x) * scale, - c.y + (uv.y - c.y) * scale); + var vertices = pair.Value; + Vector2 c = Vector2.zero; + foreach (int idx in vertices) c += uv2[idx]; + c /= vertices.Count; + foreach (int idx in vertices) + uv2[idx] = c + (uv2[idx] - c) * scale; } + if (scale < appliedScaleMin) appliedScaleMin = scale; + if (scale > appliedScaleMax) appliedScaleMax = scale; modified++; } @@ -1238,7 +1243,7 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) if (opts.postPackDensityCorrection) { - ApplyPostPackDensityCorrection(uv2, tris, positions, shells, mesh.name); + ApplyPostPackDensityCorrection(uv2, tris, positions, shells, vertChartId, mesh.name); LogPostPackDensity(uv2, tris, positions, shells, mesh.name + " [postCorrection]"); } @@ -1590,7 +1595,7 @@ static async Task RepackMultiCore(Mesh[] meshes, RepackOptions o if (opts.postPackDensityCorrection) { - ApplyPostPackDensityCorrection(uv2, allTris[m], allPositions[m], allShells[m], meshes[m].name); + ApplyPostPackDensityCorrection(uv2, allTris[m], allPositions[m], allShells[m], vertChartId, meshes[m].name); LogPostPackDensity(uv2, allTris[m], allPositions[m], allShells[m], meshes[m].name + " [postCorrection]"); } diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs index 4ccaa92e..82b5012c 100644 --- a/Tests/Editor/XatlasRepackGroupMergeTests.cs +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -339,5 +339,46 @@ public void ApplyUv2_IsAvailable_AfterSourceOnlyRepack() Assert.IsTrue((bool)method.Invoke(null, new object[] { false, true })); Assert.IsFalse((bool)method.Invoke(null, new object[] { false, false })); } + + [Test] + public void PostPackDensityCorrection_KeepsEachSplitChartCentroidFixed() + { + var method = typeof(XatlasRepack).GetMethod( + "ApplyPostPackDensityCorrection", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method); + + var uv2 = new[] + { + new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 1), + new Vector2(10, 0), new Vector2(11, 0), new Vector2(10, 1), + new Vector2(4, 0), new Vector2(4.2f, 0), new Vector2(4, 0.2f), + new Vector2(7, 0), new Vector2(7.2f, 0), new Vector2(7, 0.2f) + }; + var positions = new Vector3[uv2.Length]; + for (int i = 0; i < positions.Length; i += 3) + { + positions[i] = Vector3.zero; + positions[i + 1] = Vector3.right; + positions[i + 2] = Vector3.up; + } + var tris = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; + var shells = new List + { + new UvShell { faceIndices = new List { 0, 1 }, vertexIndices = new HashSet { 0, 1, 2, 3, 4, 5 } }, + new UvShell { faceIndices = new List { 2 }, vertexIndices = new HashSet { 6, 7, 8 } }, + new UvShell { faceIndices = new List { 3 }, vertexIndices = new HashSet { 9, 10, 11 } } + }; + var chartIds = new uint[] { 10, 10, 10, 20, 20, 20, 30, 30, 30, 40, 40, 40 }; + Vector2 firstCentroid = (uv2[0] + uv2[1] + uv2[2]) / 3f; + Vector2 secondCentroid = (uv2[3] + uv2[4] + uv2[5]) / 3f; + + int modified = (int)method.Invoke(null, + new object[] { uv2, tris, positions, shells, chartIds, "split-chart-test" }); + + Assert.AreEqual(1, modified); + Assert.That(Vector2.Distance(firstCentroid, (uv2[0] + uv2[1] + uv2[2]) / 3f), Is.LessThan(1e-5f)); + Assert.That(Vector2.Distance(secondCentroid, (uv2[3] + uv2[4] + uv2[5]) / 3f), Is.LessThan(1e-5f)); + } } } From fe5c21028dd2583598ed61531464ddfd1f72920c Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:45:22 +0200 Subject: [PATCH 38/76] fix(repack): keep UV2 clamp toggle independently editable (#172) opts.clampLightmapToUnit is applied by XatlasRepack regardless of texel density normalization, but the toggle sat inside the DisabledScope gated on Normalize texel density, so the setting could not be changed with normalize off. Move it out of that scope. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 44ae7fb5..7123a024 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -1206,12 +1206,12 @@ void DrawRepackDensityControls() "Fraction of [0,1]² normalized UVs sum to. Lower → safer fit, smaller charts; " + "higher → tighter pack but risk of overflow + downscale."), ctx.TargetUvCoverage, 0.3f, 0.95f); - ctx.ClampLightmapToUnit = EditorGUILayout.ToggleLeft( - new GUIContent("Clamp UV2 to [0,1]", - "Cheap safety net against verts pushed a fraction of a texel outside the unit square."), - ctx.ClampLightmapToUnit); EditorGUI.indentLevel--; } + ctx.ClampLightmapToUnit = EditorGUILayout.ToggleLeft( + new GUIContent("Clamp UV2 to [0,1]", + "Cheap safety net against verts pushed a fraction of a texel outside the unit square."), + ctx.ClampLightmapToUnit); } void DrawRepackCompressionControls() From 135f7c437f3f7aeaac43c35da703b1b6ea95d9bc Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:45:25 +0200 Subject: [PATCH 39/76] fix(repack): forward RepackUv rotate flag to xatlas (#173) RepackUv accepted a rotate parameter but never assigned it to opts.rotateCharts, so callers silently got the default rotation behaviour. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/XatlasRepack.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index d98f8c7c..d2481626 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -982,6 +982,7 @@ public static Vector2[] RepackUv(Mesh mesh, Vector2[] uv0, uint[] faceShellIds, var opts = RepackOptions.Default; opts.resolution = (uint)resolution; opts.padding = (uint)padding; + opts.rotateCharts = rotate; // Work on a temporary copy so original mesh is untouched var tmp = UnityEngine.Object.Instantiate(mesh); tmp.name = mesh.name + "_repack_tmp"; From 0e7dd50099cae20222172e802e1bd1a59de77ac1 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:39:11 +0200 Subject: [PATCH 40/76] fix(ao): defer GPU AO cleanup until queued work completes (#140) Cancel() released ComputeBuffers while dispatches and AsyncGPUReadback requests could still reference them. Add a Cancelling phase with a readback barrier so Cleanup runs only once the GPU is done. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.Gpu.cs | 48 +++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/Editor/VertexAOBaker.Gpu.cs b/Editor/VertexAOBaker.Gpu.cs index ceeaa2f7..0dc6a0c8 100644 --- a/Editor/VertexAOBaker.Gpu.cs +++ b/Editor/VertexAOBaker.Gpu.cs @@ -56,7 +56,7 @@ internal static GpuAOBakeJob StartGPUBake( /// internal class GpuAOBakeJob { - enum Phase { Dispatching, ReadingBack, Done, Cancelled } + enum Phase { Dispatching, ReadingBack, Cancelling, Done, Cancelled } Phase phase = Phase.Done; readonly ComputeShader cs; @@ -81,6 +81,8 @@ struct MeshSlot // Readback requests (one per mesh) AsyncGPUReadbackRequest[] readbackRequests; + AsyncGPUReadbackRequest cancellationBarrier; + bool hasCancellationBarrier; // Direction batching int dirCount; @@ -101,7 +103,9 @@ struct MeshSlot totalDispatches > 0 ? (float)completedDispatches / totalDispatches : 0f; /// True while the job is actively running. - public bool IsRunning => phase == Phase.Dispatching || phase == Phase.ReadingBack; + public bool IsRunning => phase == Phase.Dispatching || + phase == Phase.ReadingBack || + phase == Phase.Cancelling; public string StatusText { @@ -114,6 +118,8 @@ public string StatusText $"batch {curBatch + 1}/{totalBatches}"; case Phase.ReadingBack: return "Reading back results..."; + case Phase.Cancelling: + return "Cancelling..."; default: return ""; } @@ -281,8 +287,21 @@ public void Start() public void Cancel() { if (!IsRunning) return; - phase = Phase.Cancelled; - Cleanup(); + + if (phase == Phase.Cancelling) + return; + + // Do not release buffers while previously queued dispatches or readbacks + // may still reference them. A readback queued after the dispatches acts as + // a completion barrier; existing final readbacks provide the same guarantee. + if (phase == Phase.Dispatching && completedDispatches > 0) + { + int barrierMesh = Mathf.Clamp(curMesh, 0, slots.Length - 1); + cancellationBarrier = AsyncGPUReadback.Request(slots[barrierMesh].counterBuf); + hasCancellationBarrier = true; + } + + phase = Phase.Cancelling; } void Tick() @@ -297,6 +316,9 @@ void Tick() case Phase.ReadingBack: TickReadback(); break; + case Phase.Cancelling: + TickCancelling(); + break; default: EditorApplication.update -= Tick; break; @@ -399,6 +421,24 @@ void TickReadback() onComplete?.Invoke(result); } + void TickCancelling() + { + if (hasCancellationBarrier && !cancellationBarrier.done) + return; + + if (readbackRequests != null) + { + for (int i = 0; i < readbackRequests.Length; i++) + { + if (!readbackRequests[i].done) + return; + } + } + + phase = Phase.Cancelled; + Cleanup(); + } + void Cleanup() { EditorApplication.update -= Tick; From 8b5b28345d6331f7df0214260619a516dc28e156 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:44:41 +0200 Subject: [PATCH 41/76] fix(ao): guard AO blur against incomplete mesh attributes (#163) BlurAO indexed normals/uv0 by vertex index while callers pass the raw mesh arrays, which Unity returns empty when the attribute is absent. Treat length-mismatched attribute arrays as unavailable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.Blur.cs | 6 ++++ Tests/Editor/VertexAOBakerBlurTests.cs | 35 +++++++++++++++++++++ Tests/Editor/VertexAOBakerBlurTests.cs.meta | 11 +++++++ 3 files changed, 52 insertions(+) create mode 100644 Tests/Editor/VertexAOBakerBlurTests.cs create mode 100644 Tests/Editor/VertexAOBakerBlurTests.cs.meta diff --git a/Editor/VertexAOBaker.Blur.cs b/Editor/VertexAOBaker.Blur.cs index 631fca79..87f4799f 100644 --- a/Editor/VertexAOBaker.Blur.cs +++ b/Editor/VertexAOBaker.Blur.cs @@ -12,6 +12,12 @@ public static float[] BlurAO(float[] ao, int[] triangles, int vertexCount, int i { if (ao == null || iterations <= 0) return ao; + // Mesh attributes are optional and Unity may return empty arrays when they + // are not stored. Treat incomplete attributes as unavailable so seam + // comparisons never index past their bounds. + if (normals != null && normals.Length != vertexCount) normals = null; + if (uv0 != null && uv0.Length != vertexCount) uv0 = null; + // Build adjacency: vertex → set of neighbor vertices var neighbors = new List[vertexCount]; for (int i = 0; i < vertexCount; i++) diff --git a/Tests/Editor/VertexAOBakerBlurTests.cs b/Tests/Editor/VertexAOBakerBlurTests.cs new file mode 100644 index 00000000..853da2ee --- /dev/null +++ b/Tests/Editor/VertexAOBakerBlurTests.cs @@ -0,0 +1,35 @@ +// VertexAOBakerBlurTests.cs — regression coverage for optional mesh attributes. + +using NUnit.Framework; +using UnityEngine; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class VertexAOBakerBlurTests + { + [Test] + public void BlurAO_EmptyNormalsWithDuplicatePositions_DoesNotThrow() + { + var ao = new[] { 0f, 1f, 0.5f }; + var positions = new[] + { + Vector3.zero, + Vector3.zero, + Vector3.right, + }; + + float[] result = null; + Assert.DoesNotThrow(() => result = VertexAOBaker.BlurAO( + ao, new int[0], ao.Length, 1, 1f, + positions, new Vector3[0], null, + false, true)); + + // The two coincident vertices are seam-connected (missing normals are + // treated as "unavailable", not as a zero vector), so their AO swaps. + // The isolated vertex has no neighbors and keeps its value. + Assert.That(result[0], Is.EqualTo(1f).Within(0.0001f)); + Assert.That(result[1], Is.EqualTo(0f).Within(0.0001f)); + Assert.That(result[2], Is.EqualTo(0.5f).Within(0.0001f)); + } + } +} diff --git a/Tests/Editor/VertexAOBakerBlurTests.cs.meta b/Tests/Editor/VertexAOBakerBlurTests.cs.meta new file mode 100644 index 00000000..acb996eb --- /dev/null +++ b/Tests/Editor/VertexAOBakerBlurTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b280f2edbf7b4ad19793206b9458aabe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From ddbadcc7896a187600e2ae5102eea93117a565ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:05:18 +0000 Subject: [PATCH 42/76] fix(ao): bound Vertex AO seam matching work (#143) Seam matching compared every vertex in a cell against every vertex in each of the 27 neighbor cells, which is quadratic on meshes with many coincident vertices. Walk each vertex once, key cells by Vector3Int so distinct cells cannot collide, and cap the position-matched candidates per vertex. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.Blur.cs | 77 +++++++++++++------------- Tests/Editor/VertexAOBakerBlurTests.cs | 17 ++++++ 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/Editor/VertexAOBaker.Blur.cs b/Editor/VertexAOBaker.Blur.cs index 87f4799f..73b00cd2 100644 --- a/Editor/VertexAOBaker.Blur.cs +++ b/Editor/VertexAOBaker.Blur.cs @@ -6,6 +6,11 @@ namespace SashaRX.UnityMeshLab { public static partial class VertexAOBaker { + // Seam matching is a best-effort supplement to triangle adjacency. Bounding the + // position-matched candidates per vertex prevents dense or degenerate meshes from + // creating a quadratic number of comparisons and neighbor entries. + const int MaxSeamCandidatesPerVertex = 256; + public static float[] BlurAO(float[] ao, int[] triangles, int vertexCount, int iterations, float strength, Vector3[] positions = null, Vector3[] normals = null, Vector2[] uv0 = null, bool crossHardEdges = true, bool crossUvSeams = true) @@ -41,14 +46,14 @@ public static float[] BlurAO(float[] ao, int[] triangles, int vertexCount, int i const float uvEps = 1e-4f; float cellSize = posEps * 10f; // grid cell larger than epsilon - var posMap = new Dictionary>(); + var posMap = new Dictionary>(); for (int i = 0; i < vertexCount; i++) { // Use RoundToInt for stable bucketing at cell boundaries int cx = Mathf.RoundToInt(positions[i].x / cellSize); int cy = Mathf.RoundToInt(positions[i].y / cellSize); int cz = Mathf.RoundToInt(positions[i].z / cellSize); - long key = ((long)cx * 73856093L) ^ ((long)cy * 19349663L) ^ ((long)cz * 83492791L); + var key = new Vector3Int(cx, cy, cz); if (!posMap.TryGetValue(key, out var list)) { list = new List(); @@ -57,43 +62,34 @@ public static float[] BlurAO(float[] ao, int[] triangles, int vertexCount, int i list.Add(i); } - // Check each vertex against same cell + 26 neighbors - var processed = new HashSet(); - foreach (var kvp in posMap) - { - var group = kvp.Value; - // Match within same cell - for (int i = 0; i < group.Count; i++) - for (int j = i + 1; j < group.Count; j++) - TryConnectSeamVerts(neighbors, positions, normals, uv0, - group[i], group[j], posEpsSq, normThresh, uvEps, - crossHardEdges, crossUvSeams); - } - - // Also check across adjacent cells - var keys = new List(posMap.Keys); - foreach (var key in keys) + // Check every vertex against its own cell + the 26 neighbors. Pairs are + // considered once (at their lower index); only candidates that actually + // sit on top of the vertex consume the per-vertex budget. + for (int vi = 0; vi < vertexCount; vi++) { - var group = posMap[key]; - // Reconstruct cell coords from first vertex - var p0 = positions[group[0]]; - int bx = Mathf.RoundToInt(p0.x / cellSize); - int by = Mathf.RoundToInt(p0.y / cellSize); - int bz = Mathf.RoundToInt(p0.z / cellSize); - - for (int dx = -1; dx <= 1; dx++) - for (int dy = -1; dy <= 1; dy++) - for (int dz = -1; dz <= 1; dz++) + var p = positions[vi]; + int bx = Mathf.RoundToInt(p.x / cellSize); + int by = Mathf.RoundToInt(p.y / cellSize); + int bz = Mathf.RoundToInt(p.z / cellSize); + int matched = 0; + + for (int dx = -1; dx <= 1 && matched < MaxSeamCandidatesPerVertex; dx++) + for (int dy = -1; dy <= 1 && matched < MaxSeamCandidatesPerVertex; dy++) + for (int dz = -1; dz <= 1 && matched < MaxSeamCandidatesPerVertex; dz++) { - if (dx == 0 && dy == 0 && dz == 0) continue; - long nkey = ((long)(bx+dx) * 73856093L) ^ ((long)(by+dy) * 19349663L) ^ ((long)(bz+dz) * 83492791L); - if (!posMap.TryGetValue(nkey, out var ngroup)) continue; + var nkey = new Vector3Int(bx + dx, by + dy, bz + dz); + if (!posMap.TryGetValue(nkey, out var group)) continue; + + for (int i = 0; i < group.Count && matched < MaxSeamCandidatesPerVertex; i++) + { + int vj = group[i]; + if (vj <= vi) continue; - foreach (int vi in group) - foreach (int vj in ngroup) - TryConnectSeamVerts(neighbors, positions, normals, uv0, + if (TryConnectSeamVerts(neighbors, positions, normals, uv0, vi, vj, posEpsSq, normThresh, uvEps, - crossHardEdges, crossUvSeams); + crossHardEdges, crossUvSeams)) + matched++; + } } } } @@ -200,12 +196,17 @@ static long PackKey(int x, int y, int z) return ((long)x * 73856093L) ^ ((long)y * 19349663L) ^ ((long)z * 83492791L); } - static void TryConnectSeamVerts(List[] neighbors, + /// + /// Connects two seam candidates when their attributes allow it. + /// Returns true when the two vertices share a position (i.e. the candidate was + /// a real seam duplicate), regardless of whether it was finally connected. + /// + static bool TryConnectSeamVerts(List[] neighbors, Vector3[] positions, Vector3[] normals, Vector2[] uv0, int vi, int vj, float posEpsSq, float normThresh, float uvEps, bool crossHardEdges, bool crossUvSeams) { - if ((positions[vi] - positions[vj]).sqrMagnitude > posEpsSq) return; + if ((positions[vi] - positions[vj]).sqrMagnitude > posEpsSq) return false; bool normalsMatch = normals == null || Vector3.Dot(normals[vi], normals[vj]) >= normThresh; @@ -214,6 +215,8 @@ static void TryConnectSeamVerts(List[] neighbors, if ((normalsMatch || crossHardEdges) && (uvsMatch || crossUvSeams)) AddNeighbor(neighbors, vi, vj); + + return true; } static void AddNeighbor(List[] neighbors, int a, int b) diff --git a/Tests/Editor/VertexAOBakerBlurTests.cs b/Tests/Editor/VertexAOBakerBlurTests.cs index 853da2ee..7c209e35 100644 --- a/Tests/Editor/VertexAOBakerBlurTests.cs +++ b/Tests/Editor/VertexAOBakerBlurTests.cs @@ -31,5 +31,22 @@ public void BlurAO_EmptyNormalsWithDuplicatePositions_DoesNotThrow() Assert.That(result[1], Is.EqualTo(0f).Within(0.0001f)); Assert.That(result[2], Is.EqualTo(0.5f).Within(0.0001f)); } + + [Test, Timeout(15000)] + public void BlurAO_DenseCoincidentVertices_CompletesWithBoundedWork() + { + const int vertexCount = 2000; + var ao = new float[vertexCount]; + var positions = new Vector3[vertexCount]; + for (int i = 0; i < vertexCount; i++) + ao[i] = i % 2; + + var result = VertexAOBaker.BlurAO( + ao, new int[0], vertexCount, 1, 1f, positions); + + Assert.AreEqual(vertexCount, result.Length); + Assert.AreNotEqual(ao[0], result[0], + "Seam blur should remain functional while bounding dense-mesh work."); + } } } From c1eeb199dba149b32509e41c7066f9222e39158d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:06:31 +0000 Subject: [PATCH 43/76] fix(ao): bound 3D AO blur work on dense meshes (#144) BlurAO3D scanned every vertex in the 27-cell neighborhood, which degenerates to the whole mesh when a small radius puts all vertices in one cell. Cap the candidates per vertex, rotate the sample window so the cap does not always favor the lowest indices, and bail out when the position array does not match the AO array. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.Blur.cs | 24 ++++++++++++++---- Tests/Editor/VertexAOBakerBlurTests.cs | 34 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/Editor/VertexAOBaker.Blur.cs b/Editor/VertexAOBaker.Blur.cs index 73b00cd2..ee424db8 100644 --- a/Editor/VertexAOBaker.Blur.cs +++ b/Editor/VertexAOBaker.Blur.cs @@ -11,6 +11,10 @@ public static partial class VertexAOBaker // creating a quadratic number of comparisons and neighbor entries. const int MaxSeamCandidatesPerVertex = 256; + // Keeps the editor-triggered 3D spatial blur bounded when the grid degenerates + // into a single cell (tiny radius on a dense mesh, or fully coincident vertices). + const int MaxSpatialNeighborsPerVertex = 256; + public static float[] BlurAO(float[] ao, int[] triangles, int vertexCount, int iterations, float strength, Vector3[] positions = null, Vector3[] normals = null, Vector2[] uv0 = null, bool crossHardEdges = true, bool crossUvSeams = true) @@ -122,6 +126,7 @@ public static float[] BlurAO3D(float[] ao, Vector3[] positions, int iterations, { if (ao == null || positions == null || iterations <= 0 || radius <= 0) return ao; int count = ao.Length; + if (positions.Length != count) return ao; // Build spatial grid for fast neighbor lookup float cellSize = radius; @@ -152,18 +157,27 @@ public static float[] BlurAO3D(float[] ao, Vector3[] positions, int iterations, float weightSum = 1f; // self weight float aoSum = src[v]; + int checks = 0; - // 27-cell neighborhood - for (int dx = -1; dx <= 1; dx++) - for (int dy = -1; dy <= 1; dy++) - for (int dz = -1; dz <= 1; dz++) + // 27-cell neighborhood, with a per-vertex candidate budget so dense + // cells cannot turn this into O(n²). + for (int dx = -1; dx <= 1 && checks < MaxSpatialNeighborsPerVertex; dx++) + for (int dy = -1; dy <= 1 && checks < MaxSpatialNeighborsPerVertex; dy++) + for (int dz = -1; dz <= 1 && checks < MaxSpatialNeighborsPerVertex; dz++) { long nkey = PackKey(cx + dx, cy + dy, cz + dz); if (!grid.TryGetValue(nkey, out var cell)) continue; - for (int ci = 0; ci < cell.Count; ci++) + // Rotate the sample window per vertex so a cell that exceeds the + // budget does not always contribute its lowest indices. + int start = cell.Count > 0 ? v % cell.Count : 0; + for (int offset = 0; + offset < cell.Count && checks < MaxSpatialNeighborsPerVertex; + offset++) { + int ci = (start + offset) % cell.Count; int ni = cell[ci]; if (ni == v) continue; + checks++; float distSq = (positions[ni] - p).sqrMagnitude; if (distSq >= radiusSq) continue; diff --git a/Tests/Editor/VertexAOBakerBlurTests.cs b/Tests/Editor/VertexAOBakerBlurTests.cs index 7c209e35..a5bbb8e1 100644 --- a/Tests/Editor/VertexAOBakerBlurTests.cs +++ b/Tests/Editor/VertexAOBakerBlurTests.cs @@ -1,5 +1,7 @@ -// VertexAOBakerBlurTests.cs — regression coverage for optional mesh attributes. +// VertexAOBakerBlurTests.cs — regression coverage for the AO blur passes: +// optional mesh attributes and bounded work on dense/degenerate meshes. +using System.Diagnostics; using NUnit.Framework; using UnityEngine; @@ -48,5 +50,35 @@ public void BlurAO_DenseCoincidentVertices_CompletesWithBoundedWork() Assert.AreNotEqual(ao[0], result[0], "Seam blur should remain functional while bounding dense-mesh work."); } + + [Test] + public void BlurAO3D_SmallNeighborhood_AveragesAllVertices() + { + float[] ao = { 0f, 0.5f, 1f }; + var positions = new[] { Vector3.zero, Vector3.zero, Vector3.zero }; + + float[] result = VertexAOBaker.BlurAO3D(ao, positions, 1, 1f, 1f); + + Assert.That(result[0], Is.EqualTo(0.5f).Within(0.0001f)); + Assert.That(result[1], Is.EqualTo(0.5f).Within(0.0001f)); + Assert.That(result[2], Is.EqualTo(0.5f).Within(0.0001f)); + } + + [Test, Timeout(30000)] + public void BlurAO3D_DenseMesh_CompletesWithinWorkBudget() + { + const int vertexCount = 50000; + var ao = new float[vertexCount]; + var positions = new Vector3[vertexCount]; + for (int i = 0; i < vertexCount; i++) + ao[i] = i % 2; + + var stopwatch = Stopwatch.StartNew(); + float[] result = VertexAOBaker.BlurAO3D(ao, positions, 10, 1f, 0.01f); + stopwatch.Stop(); + + Assert.That(result, Has.Length.EqualTo(vertexCount)); + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(20000)); + } } } From 42feec88c5448c3766bfa964ecdc9b2a57ec1129 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:42:37 +0200 Subject: [PATCH 44/76] fix(ao): make atomic AO accumulation NaN-safe (#147) A NaN weight made the `initial != CompareExchange(...)` guard always true, so AtomicAdd spun forever inside Parallel.For and hung the editor. Compare raw bits instead, which also fixes lost updates on -0.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Editor/VertexAOBaker.cs b/Editor/VertexAOBaker.cs index dff423dc..2c2d1100 100644 --- a/Editor/VertexAOBaker.cs +++ b/Editor/VertexAOBaker.cs @@ -315,7 +315,8 @@ static void InterlockedAddDouble(ref double location, double value) initial = location; computed = initial + value; } - while (initial != Interlocked.CompareExchange(ref location, computed, initial)); + while (BitConverter.DoubleToInt64Bits(initial) != + BitConverter.DoubleToInt64Bits(Interlocked.CompareExchange(ref location, computed, initial))); } static float ComputeMedianTriArea(Vector3[] verts, int[] tris, Matrix4x4 xform) From 454d491e27cbefc2feaa0202ca0d1f85f0dbdf6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:07:53 +0000 Subject: [PATCH 45/76] fix(ao): bound face-area AO correction on degenerate meshes (#148) An all-degenerate median made largeThreshold 0, so every triangle ran the full 4x256-ray surface probe. Ignore zero-area and non-finite faces when computing the median, bail out when no usable scale exists, and poll UvProgress.CancelRequested so the pass can be interrupted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.cs | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/Editor/VertexAOBaker.cs b/Editor/VertexAOBaker.cs index 2c2d1100..ed563848 100644 --- a/Editor/VertexAOBaker.cs +++ b/Editor/VertexAOBaker.cs @@ -203,13 +203,20 @@ public static float[] FaceAreaCorrection( var correction = new double[vertCount]; var totalWeight = new double[vertCount]; - // Median area to define "large" triangle threshold + // Median area to define "large" triangle threshold. Degenerate triangles are + // ignored: they describe no surface, and a mesh padded with zero-area faces + // would otherwise drive the threshold to 0 and make every face "large". float medianArea = ComputeMedianTriArea(verts, tris, xform); + if (medianArea <= 0f || float.IsNaN(medianArea) || float.IsInfinity(medianArea)) + return (float[])ao.Clone(); + float largeThreshold = medianArea * 4f; int triCount = tris.Length / 3; - Parallel.For(0, triCount, ti => + Parallel.For(0, triCount, (ti, loopState) => { + if (UvProgress.CancelRequested) { loopState.Stop(); return; } + int t = ti * 3; int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; Vector3 p0 = xform.MultiplyPoint3x4(verts[i0]); @@ -241,6 +248,12 @@ public static float[] FaceAreaCorrection( for (int d = 0; d < directions.Length; d++) { + if ((d & 63) == 0 && UvProgress.CancelRequested) + { + loopState.Stop(); + return; + } + float ndot = Vector3.Dot(directions[d], faceNorm); if (ndot <= 0) continue; totW += ndot; @@ -272,7 +285,7 @@ public static float[] FaceAreaCorrection( // Surface must be significantly brighter than the dark vertices if (surfaceAO <= vertexAvgAO + 0.1f) return; - double weight = area / medianArea; + double weight = (double)area / medianArea; double sao = surfaceAO; // Only correct vertices that are very dark (< 0.2) @@ -322,17 +335,21 @@ static void InterlockedAddDouble(ref double location, double value) static float ComputeMedianTriArea(Vector3[] verts, int[] tris, Matrix4x4 xform) { int triCount = tris.Length / 3; - if (triCount == 0) return 1f; - var areas = new float[triCount]; + if (triCount == 0) return 0f; + var areas = new List(triCount); for (int t = 0; t < triCount; t++) { Vector3 p0 = xform.MultiplyPoint3x4(verts[tris[t * 3]]); Vector3 p1 = xform.MultiplyPoint3x4(verts[tris[t * 3 + 1]]); Vector3 p2 = xform.MultiplyPoint3x4(verts[tris[t * 3 + 2]]); - areas[t] = Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5f; + float area = Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5f; + // Degenerate / non-finite faces carry no scale information. + if (area > 0f && !float.IsNaN(area) && !float.IsInfinity(area)) + areas.Add(area); } - Array.Sort(areas); - return areas[triCount / 2]; + if (areas.Count == 0) return 0f; + areas.Sort(); + return areas[areas.Count / 2]; } // ── Shared Helpers ── From 4a6ba2e8fca3e95135aa3cfa8bdfd34711133ea6 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:45:48 +0200 Subject: [PATCH 46/76] fix(ao): clear stale AO results before loading mesh data (#177) LoadFromMesh nulled bakedRawAO on failure but left bakedFinalAO populated, so Apply could write the previous bake's results onto the newly loaded meshes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/VertexAOTool.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Editor/Tools/VertexAOTool.cs b/Editor/Tools/VertexAOTool.cs index f823a208..499d4c48 100644 --- a/Editor/Tools/VertexAOTool.cs +++ b/Editor/Tools/VertexAOTool.cs @@ -1166,6 +1166,7 @@ void FinalizeBake(List entries) void LoadFromMesh() { RestorePreview(); + ClearResults(); var entries = ctx.MeshEntries .Where(e => e.include && e.renderer != null) From 362bd2323b78b13da8f093ff2fa881d858629f0c Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:42:29 +0200 Subject: [PATCH 47/76] fix(ao): prevent duplicate Vertex AO preview backups (#145) A renderer listed in two LOD levels produced duplicate MeshEntries, so ActivatePreview backed up the already-swapped clone and RestorePreview then destroyed the real working mesh. Skip mesh filters that were already previewed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/VertexAOTool.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Editor/Tools/VertexAOTool.cs b/Editor/Tools/VertexAOTool.cs index 499d4c48..3a2a993d 100644 --- a/Editor/Tools/VertexAOTool.cs +++ b/Editor/Tools/VertexAOTool.cs @@ -1446,6 +1446,7 @@ void ActivatePreview() previewMaterial.SetInt("_ZWrite", 1); } + var previewedMeshFilters = new HashSet(); foreach (var e in ctx.MeshEntries) { if (!e.include || e.renderer == null) continue; @@ -1455,7 +1456,7 @@ void ActivatePreview() if (mesh == null || !bakedFinalAO.ContainsKey(mesh)) continue; var mr = e.renderer as MeshRenderer; - if (mr == null) continue; + if (mr == null || !previewedMeshFilters.Add(mf)) continue; // Backup previewBackups.Add((mf, mf.sharedMesh, mr.sharedMaterials)); From a2e2be8c3f70cbecddff61d51671400c16b956f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:09:17 +0000 Subject: [PATCH 48/76] fix(ao): guard vertex AO bake workload (#149) Add a pre-flight ceiling on per-LOD vertices/indices and total target vertices so an oversized or crafted LOD set is refused before any bake work starts, disposing the temporary batch meshes and naming the offending LOD in the warning. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/VertexAOTool.cs | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/Editor/Tools/VertexAOTool.cs b/Editor/Tools/VertexAOTool.cs index 3a2a993d..b246d26d 100644 --- a/Editor/Tools/VertexAOTool.cs +++ b/Editor/Tools/VertexAOTool.cs @@ -75,6 +75,15 @@ public class VertexAOTool : IUvTool bool includeCollisionOccluders; static readonly string[] bakeModeLabels = { "GPU", "CPU" }; static readonly string[] bakeTypeLabels = { "Ambient Occlusion", "Thickness" }; + // Hard safety ceilings prevent crafted or accidental LOD meshes from exhausting + // editor/GPU memory. Batches are processed separately, but their accumulated size + // still bounds the total bake time and retained result arrays. The mesh-count cap + // is deliberately generous: SameRootNearby occluder gathering legitimately + // collects hundreds of small meshes around a target. + const long MaxVerticesPerLodBatch = 5_000_000; + const long MaxIndicesPerLodBatch = 30_000_000; + const long MaxTargetVerticesPerBake = 10_000_000; + const int MaxMeshesPerLodBatch = 2048; bool applySelectedRendererOnly; bool applySelectedSubmeshOnly; int selectedSubmeshIndex; @@ -510,6 +519,14 @@ void ExecuteBake() UvtLog.Warn("[Vertex AO] No valid meshes to bake."); return; } + + if (!TryValidateBakeWorkload(batches, out string workloadError)) + { + foreach (var batch in batches) + DisposeBatchTemporaryMeshes(batch); + UvtLog.Warn("[Vertex AO] Bake aborted — " + workloadError); + return; + } StoreBatchStats(batches); bakeStopwatch = Stopwatch.StartNew(); @@ -535,6 +552,60 @@ void ExecuteBake() } } + /// + /// Pre-flight check run before any bake work starts. Refuses workloads whose + /// size would exhaust editor/GPU memory instead of discovering it mid-bake. + /// + static bool TryValidateBakeWorkload(List batches, out string error) + { + long totalTargetVertices = 0; + + foreach (var batch in batches) + { + int meshCount = batch.targetMeshes.Count + batch.occluderMeshes.Count; + if (meshCount > MaxMeshesPerLodBatch) + { + error = $"LOD{batch.lodIndex} contains {meshCount:N0} target/occluder meshes; " + + $"the safety limit is {MaxMeshesPerLodBatch:N0}. Exclude or simplify meshes before baking."; + return false; + } + + long batchVertices = 0; + ulong batchIndices = 0; + foreach (var (mesh, _) in batch.targetMeshes.Concat(batch.occluderMeshes)) + { + if (mesh == null) + continue; + + batchVertices += mesh.vertexCount; + for (int submesh = 0; submesh < mesh.subMeshCount; submesh++) + batchIndices += mesh.GetIndexCount(submesh); + + if (batchVertices > MaxVerticesPerLodBatch || batchIndices > (ulong)MaxIndicesPerLodBatch) + { + error = $"LOD{batch.lodIndex} requires {batchVertices:N0} vertices and {batchIndices:N0} indices; " + + $"the per-LOD safety limits are {MaxVerticesPerLodBatch:N0} vertices and " + + $"{MaxIndicesPerLodBatch:N0} indices. Exclude or simplify meshes before baking."; + return false; + } + } + + foreach (var (mesh, _) in batch.targetMeshes) + totalTargetVertices += mesh != null ? mesh.vertexCount : 0; + + if (totalTargetVertices > MaxTargetVerticesPerBake) + { + error = $"the selected LODs contain {totalTargetVertices:N0} target vertices " + + $"(reached at LOD{batch.lodIndex}); the per-bake safety limit is " + + $"{MaxTargetVerticesPerBake:N0}. Exclude or simplify meshes before baking."; + return false; + } + } + + error = null; + return true; + } + List BuildLodBatches(List entries, VertexAOSettings settings) { var batches = new List(); From 3d898b1fbf6b48598f206848a379e64e43c11dec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:11:02 +0000 Subject: [PATCH 49/76] fix(canvas): render Inverted triangles in validation overlay (#174) The Inverted flag was computed and tallied but never checked in either GL fill loop, so Inverted-only triangles drew as clean (or vanished entirely when the validation filter was active). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Framework/UvCanvasView.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Editor/Framework/UvCanvasView.cs b/Editor/Framework/UvCanvasView.cs index 31f8c4b9..8e5c2c94 100644 --- a/Editor/Framework/UvCanvasView.cs +++ b/Editor/Framework/UvCanvasView.cs @@ -53,6 +53,7 @@ public class ShellDebugHit public static readonly Color cNone = new Color(.3f,.3f,.3f,.3f); public static readonly Color cValClean = new Color(.2f, .85f, .3f, .4f); + public static readonly Color cValInverted = new Color(.9f, .15f, .15f, .5f); public static readonly Color cValStretch = new Color(.95f, .85f, .15f, .5f); public static readonly Color cValZero = new Color(.7f, .2f, .9f, .5f); public static readonly Color cValOOB = new Color(1f, .5f, .1f, .5f); @@ -765,6 +766,7 @@ public void GlFillValidation(float ox, float oy, float sz, Vector2[] uv, int[] t else if ((fl & TransferValidator.TriIssue.Overlap) != 0) nc = cValOverlap; else if ((fl & TransferValidator.TriIssue.OutOfBounds) != 0) nc = cValOOB; else if ((fl & TransferValidator.TriIssue.TexelDensity) != 0)nc = cValTexel; + else if ((fl & TransferValidator.TriIssue.Inverted) != 0) nc = cValInverted; else nc = cValClean; GL.Color(nc); Vx(ox, oy, sz, uv[a0]); Vx(ox, oy, sz, uv[a1]); Vx(ox, oy, sz, uv[a2]); @@ -793,6 +795,7 @@ public void GlFillValidationOverlay(float ox, float oy, float sz, Vector2[] uv, else if ((fl & TransferValidator.TriIssue.Overlap) != 0) nc = cValOverlap; else if ((fl & TransferValidator.TriIssue.OutOfBounds) != 0) nc = cValOOB; else if ((fl & TransferValidator.TriIssue.TexelDensity) != 0)nc = cValTexel; + else if ((fl & TransferValidator.TriIssue.Inverted) != 0) nc = cValInverted; else continue; GL.Color(nc); Vx(ox, oy, sz, uv[a0]); Vx(ox, oy, sz, uv[a1]); Vx(ox, oy, sz, uv[a2]); From 40f0036cf0d6d1c701dba39fbc4fa9a82fcd6c42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:11:48 +0000 Subject: [PATCH 50/76] fix(export): avoid cumulative transform baking on shared FBX meshes (#175) NormalizeExportHierarchy baked each node's transform straight into childMf.sharedMesh, which can alias the original FBX sub-asset or another node instancing the same Mesh. Bake into a per-node copy instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 7123a024..7d99415b 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -4053,7 +4053,15 @@ static Dictionary NormalizeExportHierarchy(GameObject root) var mesh = childMf.sharedMesh; if (!mesh.isReadable) continue; - BakeTransformIntoMesh(mesh, t); + // A cloned FBX hierarchy can contain several nodes that instance + // the same Mesh. Baking into sharedMesh directly would apply each + // node's transform cumulatively to that one object. Give every + // transformed node its own copy before mutating the vertex data. + var bakedMesh = UnityEngine.Object.Instantiate(mesh); + bakedMesh.name = mesh.name; + childMf.sharedMesh = bakedMesh; + + BakeTransformIntoMesh(bakedMesh, t); // Reset transform to identity t.localPosition = Vector3.zero; From d0a6ba925f08296e180d62fd1525a8ebfdc8b374 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:12:06 +0000 Subject: [PATCH 51/76] fix(hub): restore preview meshes before undo/redo refresh (#176) OnUndoRedo rebuilt MeshEntries without first turning preview off and calling RestoreWorkingMeshes, so a preview-swapped sharedMesh could be cached as the new fbxMesh baseline. Mirrors OnSelectionChange. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Framework/UvToolHub.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Editor/Framework/UvToolHub.cs b/Editor/Framework/UvToolHub.cs index 55d0fa29..fb1e4f8c 100644 --- a/Editor/Framework/UvToolHub.cs +++ b/Editor/Framework/UvToolHub.cs @@ -276,6 +276,13 @@ void OnUndoRedo() { if (ctx == null) return; + // Preview modes can temporarily replace MeshFilter.sharedMesh. Restore + // those swaps and the imported meshes before rebuilding MeshEntries, + // otherwise Refresh can cache a preview mesh as the new baseline. + if (canvas.CurrentPreviewMode != UvCanvasView.PreviewMode.Off) + ApplyPreviewMode(UvCanvasView.PreviewMode.Off); + RestoreWorkingMeshes(); + if (ctx.LodGroup != null) { ctx.Refresh(ctx.LodGroup); From 543795e337f0068ea28b2a2ab4fb761a34895821 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:12:26 +0000 Subject: [PATCH 52/76] fix(collision): actually strip extra vertex channels (#178) mesh.Clear() defaults to keepVertexLayout=true, so tangent/color/UV channels were retained once vertex data was re-set at the same count and the strip was a no-op. Pass false to drop the layout. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/CollisionMeshBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Editor/CollisionMeshBuilder.cs b/Editor/CollisionMeshBuilder.cs index 561efd55..13e8cff2 100644 --- a/Editor/CollisionMeshBuilder.cs +++ b/Editor/CollisionMeshBuilder.cs @@ -241,7 +241,7 @@ static void StripCollisionMesh(Mesh mesh) var positions = mesh.vertices; var triangles = mesh.triangles; - mesh.Clear(); + mesh.Clear(false); mesh.SetVertices(positions); mesh.SetTriangles(triangles, 0); mesh.RecalculateBounds(); From 24bb542b7c60f61beead78dbe261c238222ecc8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:13:40 +0000 Subject: [PATCH 53/76] fix(repack): normalize UV0 shell winding at the repack boundary (#180) NormalizeShellWinding was dead code and RepackSingle/RepackMulti hardcoded flippedShells=0 on the assumption that Weld had normalized winding, which it never did. Standalone Repack now normalizes its own local UV0 copies, so the caller's UV0 channel stays untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Documentation~/EXPERIMENTS.md | 11 ++++++++ Editor/XatlasRepack.cs | 15 ++++++++--- Tests/Editor/XatlasRepackGroupMergeTests.cs | 30 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 61506bb2..70031877 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -3,6 +3,17 @@ > **Обновлять этот документ при каждом эксперименте с transfer pipeline.** > Последнее обновление: v0.15.39 (2026-04-07) +## Эксперимент 2026-08-06 — Самодостаточная нормализация winding в repack + +- **Проблема:** standalone `Repack All` и отключаемый Weld в full pipeline + позволяли вызвать `RepackSingle`/`RepackMulti` с mirrored UV0 shell. +- **Изменение:** оба repack entry point нормализуют собственные копии UV0 + непосредственно перед передачей данных в xatlas; исходный UV0 mesh не + изменяется. +- **Ожидание/проверка:** mirrored shell учитывается в `flippedShells`, UV2 + упаковывается в положительном winding, а тест неизменности UV0 продолжает + проходить. + ## Эксперимент 2026-08-06 — безопасные размеры oversampled atlas - **Проблема:** умножение `resolution × internalOversample` в `uint` и расчёт diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index d2481626..a6eaeb3b 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -1037,8 +1037,11 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) // faceShellIds. A future opt-in will materialise the split. LogHardEdgeAnalysis(shells, tris, mesh.vertices, meshLabel: mesh.name); - // UV0 winding normalized by ExecWeldUv0. - result.flippedShells = 0; + // Repack is also exposed as a standalone operation, so it cannot + // rely on the optional Weld stage having normalized UV0 first. + // mesh.uv returns a copy; normalize that working copy so the + // caller's UV0 channel remains unchanged. + result.flippedShells = NormalizeShellWinding(uv0, tris, shells); // ── Flatten UV0 ── float[] uvFlat = new float[vertCount * 2]; @@ -1395,9 +1398,13 @@ static async Task RepackMultiCore(Mesh[] meshes, RepackOptions o LogHardEdgeAnalysis(shells, allTris[m], allPositions[m], meshLabel: mesh.name); } - // UV0 winding normalized by ExecWeldUv0. + // RepackMulti can be invoked directly from the Repack tab (and + // Weld is optional in the full pipeline), so normalize every + // local UV0 copy at this API boundary instead of assuming a + // previous stage ran. for (int m = 0; m < meshCount; m++) - results[m].flippedShells = 0; + results[m].flippedShells = NormalizeShellWinding( + allUv0[m], allTris[m], allShells[m]); // Local UV0 copies (flattened) per mesh — fed to xatlas, mutated // by pre-pack passes (ARAP + density normalisation + perturbation); diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs index 82b5012c..b97d1ce8 100644 --- a/Tests/Editor/XatlasRepackGroupMergeTests.cs +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -144,6 +144,36 @@ public void RepackSingle_DoesNotModifyUv0() } } + [Test] + public void RepackSingle_NormalizesMirroredShellWithoutModifyingUv0() + { + if (!NativeAvailable()) Assert.Ignore("xatlas native plugin not available"); + + var mesh = BuildTiledMesh(1); + try + { + var mirroredUv0 = mesh.uv; + for (int i = 0; i < mirroredUv0.Length; i++) + mirroredUv0[i].x = 0.5f - mirroredUv0[i].x; + mesh.uv = mirroredUv0; + + var opts = RepackOptions.Default; + opts.resolution = 256; + opts.padding = 2; + var result = XatlasRepack.RepackSingle(mesh, opts); + + Assert.IsTrue(result.ok, $"Repack failed: {result.error}"); + Assert.AreEqual(1, result.flippedShells, + "Standalone repack must normalize mirrored shells even when Weld was not run"); + CollectionAssert.AreEqual(mirroredUv0, mesh.uv, + "Repack normalization must only modify its local UV0 copy"); + } + finally + { + Object.DestroyImmediate(mesh); + } + } + [Test] public void PackPreflight_DisablesBruteForce_WhenInternalOversampleIsAboveOne() { From e0d85e4cd92a7526a7cf6d403c8780cb3798bbd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:14:04 +0000 Subject: [PATCH 54/76] fix(transfer): reject incomplete source UV channels (#183) The guards only checked for empty UV lists, but TransferCore/Extract index srcUv0/srcUv2 by vertex index, so a non-empty but short channel threw IndexOutOfRangeException. Require the channel length to match vertexCount. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/GroupedShellTransfer.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 5ca9c8de..35d51ee8 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -267,9 +267,10 @@ public static SourceShellInfo[] AnalyzeSource(Mesh sourceMesh) var uv2List = new List(); sourceMesh.GetUVs(0, uv0List); sourceMesh.GetUVs(1, uv2List); - if (uv0List.Count == 0 || uv2List.Count == 0) + if (uv0List.Count != sourceMesh.vertexCount || + uv2List.Count != sourceMesh.vertexCount) { - UvtLog.Error("[GroupedTransfer] Source mesh missing UV0 or UV2"); + UvtLog.Error("[GroupedTransfer] Source mesh has missing or incomplete UV0/UV2"); return null; } var uv0 = uv0List.ToArray(); @@ -882,8 +883,8 @@ static TransferResult TransferCore( $"oobMargin={uv2OobMargin:F6}, boundsTol={uv2BoundsTolerance:F6}"); } - if (srcUv0.Length == 0 || srcUv2.Length == 0) - { UvtLog.Error("[GroupedTransfer] Source missing UV0/UV2"); return result; } + if (srcUv0.Length != srcVerts.Length || srcUv2.Length != srcVerts.Length) + { UvtLog.Error("[GroupedTransfer] Source has missing or incomplete UV0/UV2"); return result; } UvProgress.ReportFromBackground($"'{targetMeshName}' ← '{sourceMeshName}'"); From e1218ad31a64816e0c312bce99fea5a5613d171a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:14:28 +0000 Subject: [PATCH 55/76] fix(meshopt): guard buffer sizes before the unsafe native call (#184) localVertCount * totalStride used unchecked int arithmetic to size the byte[] fed to unsafe pointer writes and the native call, so a huge mesh could overflow into an undersized buffer and write out of bounds. Size the buffers through a long-based budget check and validate the returned vertex count. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/MeshOptimizer.cs | 61 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/Editor/MeshOptimizer.cs b/Editor/MeshOptimizer.cs index cee83617..e1451230 100644 --- a/Editor/MeshOptimizer.cs +++ b/Editor/MeshOptimizer.cs @@ -25,6 +25,12 @@ public struct OptimizeResult // Some channels may store color data, bone weights, etc. const int MAX_UV_CHANNELS = 8; + // Keep user-supplied meshes away from allocations large enough to stall the + // editor, and from the unsafe/native packing path if their sizes are invalid. + const int MAX_OPTIMIZE_VERTICES = 2_000_000; + const int MAX_OPTIMIZE_INDICES = 10_000_000; + const int MAX_PACKED_BYTES = 256 * 1024 * 1024; + struct ChannelLayout { public bool hasNormal; @@ -79,6 +85,23 @@ public static OptimizeResult Optimize(Mesh mesh, float overdrawThreshold = 1.05f var layout = BuildChannelLayout(mesh); + if (!TryGetPackedByteCount(vertCount, layout.totalStride, out _)) + { + result.error = $"Mesh exceeds optimization budget ({vertCount} vertices, stride {layout.totalStride})"; + return result; + } + + long totalIndexCount = 0; + for (int s = 0; s < subCount; s++) + { + totalIndexCount += mesh.GetIndexCount(s); + if (totalIndexCount > MAX_OPTIMIZE_INDICES) + { + result.error = $"Mesh exceeds optimization budget ({totalIndexCount} indices)"; + return result; + } + } + // Build UV dim summary for log var uvDimStr = new System.Text.StringBuilder(); for (int ch = 0; ch < MAX_UV_CHANNELS; ch++) @@ -122,6 +145,7 @@ public static OptimizeResult Optimize(Mesh mesh, float overdrawThreshold = 1.05f var submeshTriangles = new List(); int totalOutVerts = 0; + int totalLocalVerts = 0; for (int s = 0; s < subCount; s++) { @@ -144,9 +168,17 @@ public static OptimizeResult Optimize(Mesh mesh, float overdrawThreshold = 1.05f int localVertCount = globalToLocal.Count; uint localIndexCount = (uint)subTris.Length; + if (!TryGetPackedByteCount(localVertCount, layout.totalStride, out int packedByteCount) || + totalLocalVerts > MAX_OPTIMIZE_VERTICES - localVertCount) + { + result.error = $"Mesh exceeds optimization budget while processing submesh {s}"; + return result; + } + totalLocalVerts += localVertCount; + // Pack vertices into interleaved byte buffer byte[] vertexBytes = PackVertices( - globalToLocal, layout, + globalToLocal, layout, packedByteCount, positions, normals, tangents, colors, uvData); // Build local index buffer @@ -155,7 +187,7 @@ public static OptimizeResult Optimize(Mesh mesh, float overdrawThreshold = 1.05f localIndices[i] = (uint)globalToLocal[subTris[i]]; // Allocate output buffers - byte[] outVertexBytes = new byte[localVertCount * layout.totalStride]; + byte[] outVertexBytes = new byte[packedByteCount]; uint[] outIndices = new uint[localIndexCount]; uint outVertCount; @@ -173,6 +205,12 @@ public static OptimizeResult Optimize(Mesh mesh, float overdrawThreshold = 1.05f return result; } + if (outVertCount > (uint)localVertCount) + { + result.error = $"meshoptOptimize returned an invalid vertex count on submesh {s}"; + return result; + } + // Unpack output vertices and append to global lists UnpackVertices( outVertexBytes, (int)outVertCount, layout, @@ -309,14 +347,27 @@ static ChannelLayout BuildChannelLayout(Mesh mesh) // ── Packing ── + static bool TryGetPackedByteCount(int vertexCount, int stride, out int byteCount) + { + byteCount = 0; + if (vertexCount < 0 || vertexCount > MAX_OPTIMIZE_VERTICES || stride < 12) + return false; + + long requiredBytes = (long)vertexCount * stride; + if (requiredBytes > MAX_PACKED_BYTES || requiredBytes > int.MaxValue) + return false; + + byteCount = (int)requiredBytes; + return true; + } + static unsafe byte[] PackVertices( Dictionary globalToLocal, - in ChannelLayout layout, + in ChannelLayout layout, int packedByteCount, Vector3[] positions, Vector3[] normals, Vector4[] tangents, Color32[] colors, List[] uvData) { - int localVertCount = globalToLocal.Count; - byte[] bytes = new byte[localVertCount * layout.totalStride]; + byte[] bytes = new byte[packedByteCount]; fixed (byte* pBytes = bytes) { From 3e82c9de158a947a88383f944fc731e21645431e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:15:49 +0000 Subject: [PATCH 56/76] docs(changelog): record consolidated security and bugfix sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the 54 incorporated PRs under Unreleased ▸ Security (CI/workflows, vendored xatlas, sidecar replay validation, resource limits, output escaping) and Unreleased ▸ Fixed (UV transfer/repack correctness, undo and preview handling, LOD parsing, FBX export, collision meshes). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f03ed638..012777b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - `[CreateAssetMenu(menuName = "Lightmap UV Tool/Test Suite")]` from `TestSuiteAsset` — replaced with a gated `[MenuItem("Assets/Create/Mesh Lab/Sweep Test Suite")]`. - Transfer tab decluttered: removed the misplaced `Generate LODs` section (full LOD-Gen UI lives in the dedicated LOD Gen tab), the `FBX Export` block (Export as New / Overwrite Source — both duplicates of the sidebar footer's `Export New FBX` / `Overwrite FBX`), and the `Save FBX from main (_main)` button (duplicate of the footer's `Backup from main`). Transfer tab now reads: per-LOD status list → `Transfer All Targets` → `Quality Report` → `Validation Overlay` → `Apply UV2` actions. +### Security +Consolidated hardening sweep — incorporates PRs #122–#123, #125–#129, #131, #134–#140, #142–#145, #147–#149, #151–#152, #156–#159, #161–#164, #168 and #170–#190. + +- **CI and build tooling** — release/version workflows no longer interpolate untrusted values straight into inline Python and no longer auto-commit built native binaries; `gen.bat` and the skills-overhaul parameter file quote/escape their arguments; `.npmignore` mirrors `.gitignore` so ignored artifacts can't leak into a published tarball (#123, #125, #135, #136, #164). +- **Vendored xatlas** — `Native~/CMakeLists.txt` builds a pinned, vendored xatlas source tree instead of `FetchContent`-ing a moving upstream branch (#127). +- **Sidecar replay validation** — `Uv2DataAsset` payloads are now treated as untrusted input: UV channel dimensions and lengths, submesh triangle counts, collision entries, vertex-color arrays, xatlas settings and the sidecar-supplied save path are validated before use, stale/incomplete remap rebuilds abort instead of half-applying, and remap work is bounded (#126, #139, #151, #182, #185, #188, #189). +- **Resource limits** — bounded or overflow-checked work budgets across Vertex/GPU AO (bake workload, seam matching, 3D and 2D blur, face-area correction, NaN-safe atomic accumulation, deferred GPU cleanup), repack (atlas oversample sizing, pack cost, exclusive native xatlas sessions, cached area preview), grouped transfer and spatial overlap scans, SceneView hover raycasts, benchmark sweep parameters and UV PNG generation, ARAP UV ranges, FBX backup path hashing, V-HACD recursion depth and triangle indices, and the meshoptimizer packing buffers feeding the unsafe/native path (#122, #128–#129, #131, #134, #137–#138, #140, #143–#144, #147–#149, #152, #162–#163, #184, #186–#187, #190). +- **Output escaping** — a shared `CsvUtil.Escape` neutralises formula prefixes in every CSV writer (sweep summaries, FBX metrics, benchmark records) and the gallery generator escapes model names before embedding them in HTML hrefs (#157–#159, #161). + +### Fixed +- **UV transfer / repack correctness** — Repack normalizes UV0 shell winding at its own API boundary instead of assuming the optional Weld stage ran; source meshes with incomplete (non-empty but short) UV0/UV2 channels are rejected before indexing; cancelled transfers drop their partial UV2 output; post-pack density correction stays inside each packed chart; `RepackUv` forwards its rotate flag to xatlas; the UV2 clamp toggle is independently editable; `Apply UV2` is reachable after a source-only repack; cross-LOD hints are isolated per mesh group (#128, #156, #170–#173, #180–#181, #183). +- **Undo and preview handling** — undo/redo restores preview-swapped meshes before rebuilding `MeshEntries` so a preview mesh can't become the cached baseline; Vertex AO no longer takes duplicate preview backups and clears stale results before loading new mesh data; the shell colour hash survives `int.MinValue`; Inverted triangles are rendered in the validation overlay instead of drawing clean or vanishing under the filter (#145, #168, #174, #176–#177). +- **LOD parsing and generation** — LOD indices parsed from object names are bounded, and LOD0's transition height is normalized before generating the remaining levels (#142, #179). +- **FBX export** — `NormalizeExportHierarchy` bakes each node's transform into a per-node mesh copy, so a shared FBX sub-asset is no longer mutated cumulatively (#175). +- **Collision meshes** — `StripCollisionMesh` calls `mesh.Clear(false)`; with the default `keepVertexLayout=true` the extra tangent/color/UV channels were retained and the strip was a no-op (#178). + ## [1.0.5] - 2026-05-13 ### Added From 78a6e7a0f547755d0fb2348731270644ef3d7bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:21:13 +0000 Subject: [PATCH 57/76] fix(export): destroy temporary mesh clones after FBX export (#175 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NormalizeExportHierarchy clones each transformed node's mesh before baking the transform into vertex data (#175). DestroyImmediate on the temp export root only frees GameObjects, so those Mesh copies leaked on every export. Collect the copies through an optional sink list and release them via a new DestroyTempMeshes helper in the same finally blocks that destroy tempRoot — after ModelExporter.ExportObjects has read their vertex data. Only meshes created by the clone step are destroyed; sharedMesh originals are untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 42 +++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 7d99415b..50c448c4 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -2891,6 +2891,8 @@ public void ExportVertexColorsToFbx() int updated = 0; Dictionary renameMap = null; + // Mesh copies created while baking node transforms — destroyed after export. + var bakedMeshes = new List(); try { updated = CopyVertexDataToClone(tempRoot); @@ -2900,7 +2902,7 @@ public void ExportVertexColorsToFbx() return; } - renameMap = NormalizeExportHierarchy(tempRoot); + renameMap = NormalizeExportHierarchy(tempRoot, bakedMeshes); PrepareCollisionMaterials(tempRoot); TrimMaterialArrays(tempRoot); @@ -2937,6 +2939,7 @@ public void ExportVertexColorsToFbx() finally { UnityEngine.Object.DestroyImmediate(tempRoot); + DestroyTempMeshes(bakedMeshes); } // ── Phase 4: Reimport (single refresh) ── @@ -3238,6 +3241,8 @@ void ExportFbx(bool overwriteSource) tempRoot.name = fbxPrefab.name; PromoteRootMeshToLod0Child(tempRoot); + // Mesh copies created while baking node transforms — destroyed after export. + var bakedMeshes = new List(); try { var lastLodRendererTemplate = FindLastLodRenderer(entries); @@ -3432,7 +3437,7 @@ void ExportFbx(bool overwriteSource) // Ensure root is a clean pivot (identity transform, no mesh) // and LOD0 child named same as root gets _LOD0 suffix. // Returns a map of oldNodeName → newNodeName for mesh re-linking. - var nodeRenameMap = NormalizeExportHierarchy(tempRoot); + var nodeRenameMap = NormalizeExportHierarchy(tempRoot, bakedMeshes); if (nodeRenameMap.Count > 0) meshRenamesByFbx[sourceFbxPath] = nodeRenameMap; @@ -3592,7 +3597,11 @@ void ExportFbx(bool overwriteSource) } } catch (Exception ex) { UvtLog.Error("[FBX Export] Export failed: " + ex); allGroupsSucceeded = false; } - finally { UnityEngine.Object.DestroyImmediate(tempRoot); } + finally + { + UnityEngine.Object.DestroyImmediate(tempRoot); + DestroyTempMeshes(bakedMeshes); + } // Restore isReadable if we changed it (non-overwrite path only; // overwrite path restores .meta from backup automatically). @@ -3956,8 +3965,13 @@ static void PromoteRootMeshToLod0Child(GameObject tempRoot) /// /// Returns a dictionary of oldNodeName → newNodeName for nodes that were renamed. /// Used to re-link scene mesh references after FBX reimport. + /// is optional — when provided, every mesh copy + /// created here is appended to it so the caller can destroy the copies once the + /// FBX export has finished (see ). /// - static Dictionary NormalizeExportHierarchy(GameObject root) + static Dictionary NormalizeExportHierarchy( + GameObject root, + List bakedMeshSink = null) { var renameMap = new Dictionary(); string baseName = root.name; @@ -4060,6 +4074,9 @@ static Dictionary NormalizeExportHierarchy(GameObject root) var bakedMesh = UnityEngine.Object.Instantiate(mesh); bakedMesh.name = mesh.name; childMf.sharedMesh = bakedMesh; + // Temporary copy — the caller destroys it after the FBX export. + if (bakedMeshSink != null) + bakedMeshSink.Add(bakedMesh); BakeTransformIntoMesh(bakedMesh, t); @@ -4072,6 +4089,23 @@ static Dictionary NormalizeExportHierarchy(GameObject root) return renameMap; } + /// + /// Destroys temporary mesh copies collected during export hierarchy setup. + /// DestroyImmediate on the temp root only frees GameObjects, so the mesh + /// copies have to be released explicitly. Call only AFTER the FBX export + /// finished — the exporter reads vertex data straight from these meshes. + /// + static void DestroyTempMeshes(List meshes) + { + if (meshes == null) return; + foreach (var mesh in meshes) + { + if (mesh != null) + UnityEngine.Object.DestroyImmediate(mesh); + } + meshes.Clear(); + } + /// /// Bake a Transform's local position/rotation/scale into mesh vertex data. /// After calling, the transform can be safely reset to identity without From 2d84f1597335c900c4def7dc985ddf0b9179b94c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:54:51 +0000 Subject: [PATCH 58/76] fix(ci): harden checkout credentials and package.json validation (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-native.yml: checkout with persist-credentials: false so the job's read-only intent isn't undermined by a token left in .git/config. - meta-check.yml / version-bump.yml: `jq empty` accepts a stream of several root values; slurp and assert a single object root instead. Verified: a two-document file passes `jq empty` and makes `jq -er .version` emit two lines. - Both workflows: strict SemVer regex — the previous one accepted leading zeros (01.2.3). - version-bump.yml: bound the patch component before $((PATCH + 1)), which wraps silently at the 64-bit boundary. Compares digit count because a numeric test on a 20-digit value errors out instead of comparing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- .github/workflows/build-native.yml | 2 ++ .github/workflows/meta-check.yml | 7 +++++-- .github/workflows/version-bump.yml | 17 ++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 9efe7bfb..21abbdaa 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -39,6 +39,8 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Configure CMake run: cmake -S 'Native~' -B build ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release diff --git a/.github/workflows/meta-check.yml b/.github/workflows/meta-check.yml index d6313198..eade19ff 100644 --- a/.github/workflows/meta-check.yml +++ b/.github/workflows/meta-check.yml @@ -84,12 +84,15 @@ jobs: - uses: actions/checkout@v4 - name: Check package.json is valid JSON - run: jq empty package.json + # `jq empty` succeeds on a stream of several root values ("{...}{...}"), + # which every JSON consumer but jq rejects. Slurping and asserting a + # single object root is what "valid package.json" actually means. + run: jq -e -s 'length == 1 and (.[0] | type == "object")' package.json - name: Check version format (semver) run: | VERSION=$(jq -er '.version | select(type == "string")' package.json) - if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "Version: $VERSION" else echo "::error::Invalid version format: $VERSION (expected semver X.Y.Z)" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index b651db92..1534c7f7 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -27,12 +27,27 @@ jobs: - name: Bump patch version in package.json id: bump run: | + # A file with several root values ("{...}{...}") passes `jq empty` but + # makes `jq -er .version` emit one line per document — validate the + # single-object root before anything reads or rewrites the manifest. + if ! jq -e -s 'length == 1 and (.[0] | type == "object")' package.json > /dev/null; then + echo "::error::package.json is not a single JSON object" + exit 1 + fi CURRENT=$(jq -er '.version | select(type == "string")' package.json) - if [[ ! "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ! "$CURRENT" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::Invalid version format: $CURRENT (expected semver X.Y.Z)" exit 1 fi IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + # $((PATCH + 1)) wraps silently at the 64-bit boundary (it would turn + # 9223372036854775807 into a negative patch), so bound the input + # first. Compare digit count, not value: a numeric [ -ge ] test on a + # 20-digit patch is itself an "integer expression expected" error. + if [ "${#PATCH}" -gt 9 ]; then + echo "::error::Patch component out of range: $PATCH (expected < 1000000000)" + exit 1 + fi NEW_PATCH=$((PATCH + 1)) NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" TEMP_FILE=$(mktemp) From a3262de8a434309444fcd1a9fc3fc042bf592763 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:55:11 +0000 Subject: [PATCH 59/76] fix(npm): exclude agent and workspace config from the published package (PR #191 review) The reported path (.codex/agents/build.toml) does not exist on this branch, but the same leak is real for .claude/: `npm pack --dry-run` listed 42 .claude/skills/** files plus .vscode/settings.json and .prettierignore in the tarball. Ignore all of them (.codex/ included for the day it appears). Verified: packed file count 237 -> 195, no dotfiles remain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- .npmignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.npmignore b/.npmignore index 2a527151..b5d47dae 100644 --- a/.npmignore +++ b/.npmignore @@ -5,6 +5,12 @@ CLAUDE.md CLAUDE.md.meta REVIEW.md REVIEW.md.meta +.claude/ +.codex/ + +# Editor/workspace config — git only +.vscode/ +.prettierignore # CI/CD & GitHub .github/ From 1424e67fc3659db61420378f61d7bdbdda93c720 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:56:30 +0000 Subject: [PATCH 60/76] fix(csv): flatten line breaks so one CSV record stays one physical line (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CsvUtil.Escape kept CR/LF inside a quoted field (valid RFC 4180), but BenchmarkSweep.AggregateRun reads the report back with File.ReadAllLines and parses each physical line as one record — an asset name containing a newline therefore shifted every column after it. Flatten CR/LF/TAB to spaces before quoting; the formula-neutralising check still runs on the original string so a leading control character keeps its apostrophe. Tests: the three CR/LF/TAB escape cases now expect flattened output, plus a line-break flattening case and a BuildCsv -> ParseCsvRow round-trip that asserts the record survives a File.ReadAllLines-style split. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/CsvUtil.cs | 16 ++++++++++-- Tests/Editor/BenchmarkRecorderTests.cs | 21 +++++++++++++--- Tests/Editor/BenchmarkSweepTests.cs | 34 ++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/Editor/CsvUtil.cs b/Editor/CsvUtil.cs index 9050b0b3..0ebe5eab 100644 --- a/Editor/CsvUtil.cs +++ b/Editor/CsvUtil.cs @@ -15,18 +15,30 @@ internal static class CsvUtil /// leading tab/CR/LF as a formula. Our fields carry user-controlled /// data (asset names, file paths), so such values get an apostrophe /// prefix that forces them to stay text. - /// 2. RFC 4180 quoting — fields containing a comma, quote or newline are + /// 2. Line-break flattening — RFC 4180 allows a newline inside a quoted + /// field, but our own reader (BenchmarkSweep.AggregateRun) splits the + /// report with File.ReadAllLines before parsing, so an embedded CR/LF + /// would split one logical record across physical lines and corrupt + /// every column after it. CR, LF and TAB become spaces so one + /// physical line always equals one logical record. + /// 3. RFC 4180 quoting — fields containing a comma or a quote are /// wrapped in double quotes with embedded quotes doubled. /// internal static string Escape(string s) { if (string.IsNullOrEmpty(s)) return ""; + // Tested against the original string: the neutralising apostrophe is + // still needed for a field that starts with a control character + // followed by a formula. if (s[0] == '=' || s[0] == '+' || s[0] == '-' || s[0] == '@' || s[0] == '\t' || s[0] == '\r' || s[0] == '\n') s = "'" + s; - bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; + if (s.IndexOfAny(new[] { '\r', '\n', '\t' }) >= 0) + s = s.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' '); + + bool needQuote = s.IndexOfAny(new[] { ',', '"' }) >= 0; if (!needQuote) return s; return "\"" + s.Replace("\"", "\"\"") + "\""; } diff --git a/Tests/Editor/BenchmarkRecorderTests.cs b/Tests/Editor/BenchmarkRecorderTests.cs index 76dcb387..e364df97 100644 --- a/Tests/Editor/BenchmarkRecorderTests.cs +++ b/Tests/Editor/BenchmarkRecorderTests.cs @@ -17,14 +17,29 @@ static string Csv(string value) [TestCase("+SUM(A1:A2)", "'+SUM(A1:A2)")] [TestCase("-1+2", "'-1+2")] [TestCase("@SUM(A1:A2)", "'@SUM(A1:A2)")] - [TestCase("\t=1+1", "'\t=1+1")] - [TestCase("\r=1+1", "\"'\r=1+1\"")] - [TestCase("\n=1+1", "\"'\n=1+1\"")] + // A leading control character still gets the neutralising apostrophe + // (the check runs on the original string), but the control character + // itself is flattened to a space so the record stays on one line. + [TestCase("\t=1+1", "' =1+1")] + [TestCase("\r=1+1", "' =1+1")] + [TestCase("\n=1+1", "' =1+1")] public void Csv_FormulaPrefix_NeutralizesCell(string value, string expected) { Assert.AreEqual(expected, Csv(value)); } + [TestCase("Mesh\nLOD0", "Mesh LOD0")] + [TestCase("Mesh\r\nLOD0", "Mesh LOD0")] + [TestCase("Mesh\tLOD0", "Mesh LOD0")] + public void Csv_EmbeddedLineBreaks_FlattenedToSpaces(string value, string expected) + { + string escaped = Csv(value); + Assert.AreEqual(expected, escaped); + Assert.IsFalse(escaped.Contains("\n") || escaped.Contains("\r"), + "A CSV field must never carry a line break — the report reader " + + "splits records with File.ReadAllLines."); + } + [Test] public void Csv_FormulaWithDelimiter_NeutralizesAndQuotesCell() { diff --git a/Tests/Editor/BenchmarkSweepTests.cs b/Tests/Editor/BenchmarkSweepTests.cs index b6da7613..a5c1d0e7 100644 --- a/Tests/Editor/BenchmarkSweepTests.cs +++ b/Tests/Editor/BenchmarkSweepTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Reflection; using NUnit.Framework; @@ -5,13 +6,22 @@ namespace SashaRX.UnityMeshLab.Tests { public class BenchmarkSweepTests { + static System.Type SweepType => + typeof(UvShellExtractor).Assembly.GetType("SashaRX.UnityMeshLab.BenchmarkSweep"); + static string EscapeCsv(string value) { - var type = typeof(UvShellExtractor).Assembly.GetType("SashaRX.UnityMeshLab.BenchmarkSweep"); - var method = type.GetMethod("Csv", BindingFlags.NonPublic | BindingFlags.Static); + var method = SweepType.GetMethod("Csv", BindingFlags.NonPublic | BindingFlags.Static); return (string)method.Invoke(null, new object[] { value }); } + static List ParseCsvRow(string line) + { + var method = SweepType.GetMethod("ParseCsvRow", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method); + return (List)method.Invoke(null, new object[] { line }); + } + [TestCase("=SUM(1,1)", "\"'=SUM(1,1)\"")] [TestCase("+cmd", "'+cmd")] [TestCase("-2+3", "'-2+3")] @@ -26,5 +36,25 @@ public void Csv_OrdinaryFilename_RemainsUnchanged() { Assert.AreEqual("report.csv", EscapeCsv("report.csv")); } + + // AggregateRun reads reports with File.ReadAllLines and parses each + // physical line as one record, so an escaped field must never introduce + // a line break — otherwise every column after it shifts. + [Test] + public void Csv_FieldWithLineBreaks_SurvivesLineSplitRoundTrip() + { + string row = string.Join(",", + EscapeCsv("Mesh\r\nLOD0"), EscapeCsv("=1,2"), EscapeCsv("7")); + + var physicalLines = row.Split('\n'); + Assert.AreEqual(1, physicalLines.Length, + "One logical record must stay on one physical line."); + + var cells = ParseCsvRow(physicalLines[0]); + Assert.AreEqual(3, cells.Count); + Assert.AreEqual("Mesh LOD0", cells[0]); + Assert.AreEqual("'=1,2", cells[1]); + Assert.AreEqual("7", cells[2]); + } } } From 8bd14b26980dcda9390ec8a4b34e8f92e3fdcea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:57:43 +0000 Subject: [PATCH 61/76] fix(partition): dedupe vertex-pair keys of degenerate triangles (PR #191 review) A face with a repeated index collapses two of its three edges onto the same unordered pair, so DetectOverlap counted that pair twice per face. The inclusion-exclusion read side subtracts the pair count once, so the face's shared-face total came out too low and the face was reported as UV overlap. Emit each distinct pair once per triangle; vertex and triple counting are unchanged (they already skip repeated indices). Verified by replaying both code paths over the test inputs: face (0,7,7) sharing vertex 0 with three other faces in the same grid cell scored 3 of 4 before and 4 of 4 after, and both existing test cases keep their results. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/SpatialPartitioner.cs | 15 ++++++++++++--- Tests/Editor/SpatialPartitionerTests.cs | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Editor/SpatialPartitioner.cs b/Editor/SpatialPartitioner.cs index 059b1a64..b36376ff 100644 --- a/Editor/SpatialPartitioner.cs +++ b/Editor/SpatialPartitioner.cs @@ -325,9 +325,18 @@ static HashSet DetectOverlap( if (i1 != i0) IncrementCount(vertexCounts, i1); if (i2 != i0 && i2 != i1) IncrementCount(vertexCounts, i2); - if (i0 != i1) IncrementCount(pairCounts, VertexPairKey(i0, i1)); - if (i0 != i2) IncrementCount(pairCounts, VertexPairKey(i0, i2)); - if (i1 != i2) IncrementCount(pairCounts, VertexPairKey(i1, i2)); + // A degenerate face repeats a vertex index, so two of its three + // edges collapse onto the same unordered pair (e.g. (0,1,1) + // yields key(0,1) twice). Counting that pair twice inflates the + // subtracted intersection term and makes the face look less + // shared than it is. Keys are order-independent and never + // negative, so -1 is a safe "no such pair" marker. + long k01 = i0 != i1 ? VertexPairKey(i0, i1) : -1L; + long k02 = i0 != i2 ? VertexPairKey(i0, i2) : -1L; + long k12 = i1 != i2 ? VertexPairKey(i1, i2) : -1L; + if (k01 >= 0) IncrementCount(pairCounts, k01); + if (k02 >= 0 && k02 != k01) IncrementCount(pairCounts, k02); + if (k12 >= 0 && k12 != k01 && k12 != k02) IncrementCount(pairCounts, k12); if (i0 != i1 && i0 != i2 && i1 != i2) IncrementCount(tripleCounts, new FaceVertexKey(i0, i1, i2)); } diff --git a/Tests/Editor/SpatialPartitionerTests.cs b/Tests/Editor/SpatialPartitionerTests.cs index 1cb03e5f..737273e6 100644 --- a/Tests/Editor/SpatialPartitionerTests.cs +++ b/Tests/Editor/SpatialPartitionerTests.cs @@ -42,6 +42,27 @@ public void PartitionShells_NonAdjacentFaceInSharedGridCells_DetectsOverlap() "A face sharing grid cells but no vertex must be treated as UV overlap"); } + [Test] + public void PartitionShells_DegenerateTriangleSharingVertex_DoesNotOverlap() + { + var uv = BuildFullBoundsUvs(4); + var triangles = new[] + { + 0, 1, 2, + 0, 3, 4, + 0, 5, 6, + 0, 7, 7 // degenerate: two of its edges are the same vertex pair + }; + + var result = PartitionSingleShell(uv, triangles); + + // The degenerate face shares vertex 0 with every other face. If its + // repeated pair key is counted twice, inclusion-exclusion subtracts + // the intersection twice and the face is falsely reported as overlap. + Assert.IsFalse(result.hasOverlap, + "A degenerate triangle must not inflate the shared-vertex pair counts"); + } + static Vector2[] BuildFullBoundsUvs(int faceCount) { var uv = new Vector2[faceCount * 2 + 2]; From d1c41bf2c267f1dd6a4359ff36220e31ce9c9a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:59:41 +0000 Subject: [PATCH 62/76] fix(lod): share LOD index parsing and apply the level cap where it bites (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TryParseLodIndex + MaxLodLevels were copy-pasted in CleanupTool and ModelBuilderTool. Both now call one internal helper in MeshHygieneUtility (name hygiene already lives there), with the regex compiled once instead of re-parsed on every call. Behaviour is identical apart from a null-name guard. The cap was only honoured on one of the three ModelBuilderTool paths: - RebuildLodGroupFromNames already went through TryParseLodIndex — unchanged. - NormalizeHierarchy numbered mesh children sequentially with no ceiling, so a root with nine mesh children got a _LOD8 name that RebuildLodGroupFromNames then silently rejected. Stop numbering at the ceiling and warn instead of inventing unusable suffixes. - AddLodLevel appended levels without a ceiling — refuse past the eighth. GetLodIndexFromName is left alone: it only builds an inspector label and never indexes a LOD array. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/MeshHygieneUtility.cs | 32 +++++++++++++++++++++++++ Editor/Tools/CleanupTool.cs | 18 ++------------ Editor/Tools/ModelBuilderTool.cs | 40 +++++++++++++++++--------------- Tests/Editor/CleanupToolTests.cs | 10 +++++++- 4 files changed, 64 insertions(+), 36 deletions(-) diff --git a/Editor/MeshHygieneUtility.cs b/Editor/MeshHygieneUtility.cs index 624564d3..9bdb4c14 100644 --- a/Editor/MeshHygieneUtility.cs +++ b/Editor/MeshHygieneUtility.cs @@ -57,6 +57,38 @@ internal static class MeshHygieneUtility @"_+", System.Text.RegularExpressions.RegexOptions.Compiled); + static readonly System.Text.RegularExpressions.Regex lodIndexSuffixRegex = + new System.Text.RegularExpressions.Regex( + @"_LOD(\d+)$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | + System.Text.RegularExpressions.RegexOptions.Compiled); + + // ── LOD naming ── + + /// + /// Highest number of LOD levels a Unity LODGroup supports. Name-derived + /// indices at or above this are rejected rather than silently clamped. + /// + internal const int MaxLodLevels = 8; + + /// + /// Parses the trailing _LOD{N} suffix of a node name. + /// Returns false when the suffix is missing, when the digits overflow + /// , or when the index is outside the LODGroup range + /// (also guards int.Parse against arbitrarily long digit runs). + /// + internal static bool TryParseLodIndex(string name, out int lodIndex) + { + lodIndex = 0; + if (string.IsNullOrEmpty(name)) return false; + + var match = lodIndexSuffixRegex.Match(name); + + return match.Success + && int.TryParse(match.Groups[1].Value, out lodIndex) + && lodIndex < MaxLodLevels; + } + // ── Name helpers ── /// diff --git a/Editor/Tools/CleanupTool.cs b/Editor/Tools/CleanupTool.cs index 9ada610f..4f8755c9 100644 --- a/Editor/Tools/CleanupTool.cs +++ b/Editor/Tools/CleanupTool.cs @@ -11,8 +11,6 @@ namespace SashaRX.UnityMeshLab { public class CleanupTool : IUvTool { - const int MaxLodLevels = 8; - UvToolContext ctx; UvCanvasView canvas; Action requestRepaint; @@ -1230,7 +1228,7 @@ void ScanScene() var child = root.GetChild(i); if (colSet.Contains(child.gameObject)) continue; - if (!TryParseLodIndex(child.name, out int lodIdx)) continue; + if (!MeshHygieneUtility.TryParseLodIndex(child.name, out int lodIdx)) continue; var r = child.GetComponent(); if (r == null) continue; @@ -1497,7 +1495,7 @@ void RebuildLodGroupFromHierarchy() var child = root.GetChild(i); if (colSet.Contains(child.gameObject)) continue; - if (!TryParseLodIndex(child.name, out int lodIdx)) continue; + if (!MeshHygieneUtility.TryParseLodIndex(child.name, out int lodIdx)) continue; var r = child.GetComponent(); if (r == null) continue; @@ -1528,18 +1526,6 @@ void RebuildLodGroupFromHierarchy() UvtLog.Info($"Rebuilt LODGroup with {maxLod} LOD level(s)."); } - static bool TryParseLodIndex(string name, out int lodIndex) - { - lodIndex = 0; - var match = System.Text.RegularExpressions.Regex.Match( - name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - return match.Success - && int.TryParse(match.Groups[1].Value, out lodIndex) - && lodIndex < MaxLodLevels; - } - // ═══════════════════════════════════════════════════════════════ // Section 4: Mesh // ═══════════════════════════════════════════════════════════════ diff --git a/Editor/Tools/ModelBuilderTool.cs b/Editor/Tools/ModelBuilderTool.cs index 34ac9ff2..bcce56c2 100644 --- a/Editor/Tools/ModelBuilderTool.cs +++ b/Editor/Tools/ModelBuilderTool.cs @@ -10,8 +10,6 @@ namespace SashaRX.UnityMeshLab { public class ModelBuilderTool : IUvTool { - const int MaxLodLevels = 8; - UvToolContext ctx; UvCanvasView canvas; System.Action requestRepaint; @@ -370,20 +368,6 @@ static int GetLodIndexFromName(string name) return match.Success && int.TryParse(match.Groups[1].Value, out int lodIdx) ? lodIdx : -1; } - // LODGroup supports at most eight levels; reject name-derived indices - // outside that range (also guards int.Parse against overflowing digits). - static bool TryParseLodIndex(string name, out int lodIndex) - { - lodIndex = 0; - var match = System.Text.RegularExpressions.Regex.Match( - name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - return match.Success - && int.TryParse(match.Groups[1].Value, out lodIndex) - && lodIndex < MaxLodLevels; - } - void DrawEditableName(GameObject go, string suffix, int indent) { if (go == null) return; @@ -562,7 +546,13 @@ void NormalizeHierarchy() { // Simple case: no existing LOD names. Single mesh per LOD tier. // Rename sequentially: baseName_LOD0, baseName_LOD1, etc. - for (int i = 0; i < meshChildren.Count; i++) + // + // Stop at the LODGroup ceiling: numbering past it produced + // _LOD8+ names that RebuildLodGroupFromNames then rejects, + // so the extra children were dropped from the LODGroup with + // no explanation. Leave their names alone and say so. + int namedLods = Mathf.Min(meshChildren.Count, MeshHygieneUtility.MaxLodLevels); + for (int i = 0; i < namedLods; i++) { string newName = baseName + "_LOD" + i; if (meshChildren[i].t.name != newName) @@ -571,6 +561,10 @@ void NormalizeHierarchy() meshChildren[i].t.name = newName; } } + if (meshChildren.Count > namedLods) + UvtLog.Warn($"{meshChildren.Count} mesh children but a LODGroup holds at most " + + $"{MeshHygieneUtility.MaxLodLevels} levels — only the {namedLods} " + + "largest received _LOD suffixes; the rest kept their names."); } } @@ -801,7 +795,7 @@ void RebuildLodGroupFromNames() if (r == null || r.transform == root) continue; if (colSet.Contains(r.gameObject)) continue; - if (!TryParseLodIndex(r.gameObject.name, out int lodIdx)) continue; + if (!MeshHygieneUtility.TryParseLodIndex(r.gameObject.name, out int lodIdx)) continue; if (!lodChildren.ContainsKey(lodIdx)) lodChildren[lodIdx] = new List(); @@ -954,8 +948,16 @@ void AddLodLevel() { if (ctx.LodGroup == null) return; - Undo.RecordObject(ctx.LodGroup, "Add LOD Level"); var lods = ctx.LodGroup.GetLODs(); + if (lods.Length >= MeshHygieneUtility.MaxLodLevels) + { + UvtLog.Warn($"LODGroup already has {lods.Length} levels — " + + $"a LODGroup holds at most {MeshHygieneUtility.MaxLodLevels}, " + + "and _LOD suffixes past that are not recognised."); + return; + } + + Undo.RecordObject(ctx.LodGroup, "Add LOD Level"); var newLods = new LOD[lods.Length + 1]; System.Array.Copy(lods, newLods, lods.Length); diff --git a/Tests/Editor/CleanupToolTests.cs b/Tests/Editor/CleanupToolTests.cs index 4dc0f26f..0809c01e 100644 --- a/Tests/Editor/CleanupToolTests.cs +++ b/Tests/Editor/CleanupToolTests.cs @@ -5,10 +5,18 @@ namespace SashaRX.UnityMeshLab.Tests { public class CleanupToolTests { + // The helper moved out of CleanupTool into the shared MeshHygieneUtility + // (CleanupTool and ModelBuilderTool both parse _LOD{N} suffixes). That + // class is internal, so it is reached through the assembly rather than + // a typeof(). static bool TryParseLodIndex(string name, out int lodIndex) { - var method = typeof(CleanupTool).GetMethod( + var type = typeof(CleanupTool).Assembly.GetType( + "SashaRX.UnityMeshLab.MeshHygieneUtility"); + Assert.IsNotNull(type, "MeshHygieneUtility not found in the editor assembly"); + var method = type.GetMethod( "TryParseLodIndex", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, "MeshHygieneUtility.TryParseLodIndex not found"); var arguments = new object[] { name, 0 }; bool result = (bool)method.Invoke(null, arguments); lodIndex = (int)arguments[1]; From cdaeae1a3cab9671bcf999d5548136f30a044ff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:01:38 +0000 Subject: [PATCH 63/76] fix(postprocess): keep sidecar colors authoritative and abort partial remaps (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed both reports by reading the allocation site: 1. optColors was allocated only when the RAW FBX mesh carried a color channel (mesh.colors32 with rawCount entries), so entry.optimizedColors — the authoritative copy, since merged and orphan vertices cannot be rebuilt from the remap — was silently dropped for any mesh whose colors were produced after import (baked vertex AO on a color-less FBX). Allocate when either source exists; the remap fallback now runs only when the sidecar has no optimized colors AND the raw mesh has some. The legacy path is unaffected: optimized colors require ground truth, which that path does not have. 2. When the remap comparison budget ran out mid-fallback, the half-filled array (zeros for everything after the cut) was handed back and written to the mesh unconditionally, flattening part of the UV channel to (0,0) on every auto-reimport. Abort the entry instead and leave the mesh untouched, matching the stale-remap abort. The primary channel also gets the length check the auxiliary channel already had. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Uv2AssetPostprocessor.cs | 53 +++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index f9d339fa..64b8a071 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -562,11 +562,16 @@ static bool ApplyEntryToMesh(Uv2DataAsset data, Mesh mesh, out ApplyStats stats) int nearestFallback, nearestAnyReuse, unmatched; int primaryTargetUvChannel = ResolvePrimaryTargetUvChannel(entry.targetUvChannel, mesh.name); string primaryLabel = $"channel {primaryTargetUvChannel}"; - var uv2 = RemapUvSetIfNeeded(entry, entry.uv2, mesh, primaryLabel, out didRemap, out nearestFallback, out nearestAnyReuse, out unmatched); + var uv2 = RemapUvSetIfNeeded(entry, entry.uv2, mesh, primaryLabel, out didRemap, out nearestFallback, out nearestAnyReuse, out unmatched, out bool remapAborted); stats.remapped = didRemap; stats.nearestFallbackCount = nearestFallback; stats.nearestAnyReuseCount = nearestAnyReuse; stats.unmatchedVerts = unmatched; + // A partially resolved remap is worse than no remap: it would flatten + // the untouched tail of the channel to (0,0). RemapUvSetIfNeeded has + // already logged the reason. + if (remapAborted || uv2 == null || uv2.Length != mesh.vertexCount) + return false; mesh.SetUVs(primaryTargetUvChannel, uv2); if (entry.auxiliaryUv != null && IsValidReplayUvChannel(entry.auxiliaryTargetUvChannel, mesh.name, "auxiliary")) @@ -581,8 +586,9 @@ static bool ApplyEntryToMesh(Uv2DataAsset data, Mesh mesh, out ApplyStats stats) out auxDidRemap, out auxNearestFallback, out auxNearestAnyReuse, - out auxUnmatched); - if (auxiliaryUv != null && auxiliaryUv.Length == mesh.vertexCount) + out auxUnmatched, + out bool auxRemapAborted); + if (!auxRemapAborted && auxiliaryUv != null && auxiliaryUv.Length == mesh.vertexCount) mesh.SetUVs(entry.auxiliaryTargetUvChannel, auxiliaryUv); } return true; @@ -782,7 +788,17 @@ static bool ReplayOptimization(Mesh mesh, MeshUv2Entry entry, bool stale = false var optPos = new Vector3[optCount]; var optNormals = rawNormals != null && rawNormals.Length == rawCount ? new Vector3[optCount] : null; var optTangents = rawTangents != null && rawTangents.Length == rawCount ? new Vector4[optCount] : null; - var optColors = rawColors != null && rawColors.Length == rawCount ? new Color32[optCount] : null; + // Colors have two possible sources and the sidecar's optimized colors + // are the authoritative one (they survive merges and orphans that the + // remap cannot reconstruct). Allocating only when the RAW FBX carries + // colors dropped them entirely for the common case of a mesh whose + // colors were produced after import — e.g. baked vertex AO — because + // the raw mesh has no color channel at all. + bool hasRawColors = rawColors != null && rawColors.Length == rawCount; + bool hasOptimizedColors = hasGroundTruth && + entry.optimizedColors != null && + entry.optimizedColors.Length == optCount; + var optColors = hasRawColors || hasOptimizedColors ? new Color32[optCount] : null; var optUvs = new List[8]; for (int ch = 0; ch < 8; ch++) @@ -803,14 +819,13 @@ static bool ReplayOptimization(Mesh mesh, MeshUv2Entry entry, bool stale = false System.Array.Copy(entry.optimizedNormals, optNormals, optCount); if (optTangents != null && entry.optimizedTangents != null && entry.optimizedTangents.Length == optCount) System.Array.Copy(entry.optimizedTangents, optTangents, optCount); - if (optColors != null && entry.optimizedColors != null && entry.optimizedColors.Length == optCount) + if (hasOptimizedColors) System.Array.Copy(entry.optimizedColors, optColors, optCount); // UV channels and legacy colors — from remap (UV0 is modified by weld, // so it must come from the raw FBX). Optimized colors remain authoritative // because merged and orphan vertices cannot always be reconstructed by remap. - bool remapColors = optColors != null && - (entry.optimizedColors == null || entry.optimizedColors.Length != optCount); + bool remapColors = optColors != null && !hasOptimizedColors && hasRawColors; for (int i = 0; i < rawCount; i++) { int dst = remap[i]; @@ -1309,12 +1324,14 @@ static Vector2[] RemapUvSetIfNeeded( out bool didRemap, out int nearestFallbackCount, out int nearestAnyReuseCount, - out int unmatchedCount) + out int unmatchedCount, + out bool aborted) { didRemap = false; nearestFallbackCount = 0; nearestAnyReuseCount = 0; unmatchedCount = 0; + aborted = false; // No position data stored — backward compat, use UV2 as-is if (sourceUv == null) return null; @@ -1488,10 +1505,6 @@ static Vector2[] RemapUvSetIfNeeded( } } - if (candidateChecksRemaining <= 0) - UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': {channelLabel} remap comparison limit reached; " + - "remaining vertices will keep zero."); - if (nearestFallbackCount > 0) UvtLog.Info($"[UV2 Postprocess] '{mesh.name}': {nearestFallbackCount} {channelLabel} vertices matched by nearest-unused fallback"); @@ -1502,9 +1515,23 @@ static Vector2[] RemapUvSetIfNeeded( } } - didRemap = true; unmatchedCount = count - matched; + // Running out of comparison budget leaves the tail of `result` at + // (0,0). Handing that back would write a half-remapped channel into + // the mesh on every auto-reimport, so abort the replay for this entry + // instead — same contract as the stale-remap abort. + if (candidateChecksRemaining <= 0 && matched < count) + { + aborted = true; + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': {channelLabel} remap comparison limit reached " + + $"with {count - matched}/{count} vertices unresolved — " + + "replay aborted, mesh left untouched."); + return null; + } + + didRemap = true; + if (matched < count) UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': {channelLabel} position remap {matched}/{count} " + "(unmatched vertices will keep zero)"); From d964ed2bcbb270eef6d1c89f4705d6bc6072a90f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:02:17 +0000 Subject: [PATCH 64/76] fix(png): reject index lists that are not whole triangles (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render already rejected too-few and too-many indices but accepted a trailing partial triangle, which every consumer then truncated with tris.Length / 3 — the diagnostic PNG would quietly not match its input. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/UvPngWriter.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Editor/UvPngWriter.cs b/Editor/UvPngWriter.cs index aa3cfa2a..287f7693 100644 --- a/Editor/UvPngWriter.cs +++ b/Editor/UvPngWriter.cs @@ -47,8 +47,13 @@ static Material GetMat() /// public static bool Render(string path, Vector2[] uv, int[] tris, int size = DefaultSize) { + // tris.Length % 3: an index list that is not a whole number of + // triangles is malformed input. Every consumer here (shell + // extraction, the fill pass, the wire pass) silently truncates it + // with tris.Length / 3, so the PNG would misrepresent the mesh it + // claims to diagnose. Reject it like the other sanity limits. if (string.IsNullOrEmpty(path) || uv == null || tris == null || - uv.Length > MaxUvCount || tris.Length < 3 || + uv.Length > MaxUvCount || tris.Length < 3 || tris.Length % 3 != 0 || tris.Length > MaxTriangleIndexCount || size <= 0 || size > MaxSize) return false; var mat = GetMat(); From 902566bd0477e45e42027d758bc7b1162818c90e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:04:01 +0000 Subject: [PATCH 65/76] fix(ao): bound seam scanning by candidates examined and drop cancelled correction (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BlurAO's seam pass only counted candidates that actually connected, so a vertex whose grid neighbourhood holds thousands of near-but-not-matching candidates still scanned all of them — the quadratic worst case the cap was supposed to remove. Add a second cap on candidates examined (4096) that bounds all three cell loops and the inner scan; the 256 matched cap is unchanged, and it still trips first on the coincident-vertex case the existing test covers. - FaceAreaCorrection applied whatever correction/totalWeight the cancelled Parallel.For had already accumulated: loopState.Stop() only stops new iterations. Record the cancellation and return the untouched AO copy; normal completion is unchanged. - VertexAOBakerBlurTests: drop the ElapsedMilliseconds < 20000 assertion (it measures the CI runner, not the budget) and the now-unused System.Diagnostics import. [Timeout(30000)] still guards the test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/VertexAOBaker.Blur.cs | 23 ++++++++++++++++++----- Editor/VertexAOBaker.cs | 16 +++++++++++++++- Tests/Editor/VertexAOBakerBlurTests.cs | 6 ++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Editor/VertexAOBaker.Blur.cs b/Editor/VertexAOBaker.Blur.cs index ee424db8..9c01f0eb 100644 --- a/Editor/VertexAOBaker.Blur.cs +++ b/Editor/VertexAOBaker.Blur.cs @@ -11,6 +11,13 @@ public static partial class VertexAOBaker // creating a quadratic number of comparisons and neighbor entries. const int MaxSeamCandidatesPerVertex = 256; + // The matched cap above only counts candidates that actually connect, so a + // vertex surrounded by thousands of near-but-not-matching candidates still + // scanned its whole neighbourhood — the quadratic worst case the cap was + // meant to remove. This second, larger cap bounds the candidates *examined*, + // whether they match or not. + const int MaxSeamComparisonsPerVertex = 4096; + // Keeps the editor-triggered 3D spatial blur bounded when the grid degenerates // into a single cell (tiny radius on a dense mesh, or fully coincident vertices). const int MaxSpatialNeighborsPerVertex = 256; @@ -76,19 +83,25 @@ public static float[] BlurAO(float[] ao, int[] triangles, int vertexCount, int i int by = Mathf.RoundToInt(p.y / cellSize); int bz = Mathf.RoundToInt(p.z / cellSize); int matched = 0; - - for (int dx = -1; dx <= 1 && matched < MaxSeamCandidatesPerVertex; dx++) - for (int dy = -1; dy <= 1 && matched < MaxSeamCandidatesPerVertex; dy++) - for (int dz = -1; dz <= 1 && matched < MaxSeamCandidatesPerVertex; dz++) + int examined = 0; + + for (int dx = -1; dx <= 1 && matched < MaxSeamCandidatesPerVertex + && examined < MaxSeamComparisonsPerVertex; dx++) + for (int dy = -1; dy <= 1 && matched < MaxSeamCandidatesPerVertex + && examined < MaxSeamComparisonsPerVertex; dy++) + for (int dz = -1; dz <= 1 && matched < MaxSeamCandidatesPerVertex + && examined < MaxSeamComparisonsPerVertex; dz++) { var nkey = new Vector3Int(bx + dx, by + dy, bz + dz); if (!posMap.TryGetValue(nkey, out var group)) continue; - for (int i = 0; i < group.Count && matched < MaxSeamCandidatesPerVertex; i++) + for (int i = 0; i < group.Count && matched < MaxSeamCandidatesPerVertex + && examined < MaxSeamComparisonsPerVertex; i++) { int vj = group[i]; if (vj <= vi) continue; + examined++; if (TryConnectSeamVerts(neighbors, positions, normals, uv0, vi, vj, posEpsSq, normThresh, uvEps, crossHardEdges, crossUvSeams)) diff --git a/Editor/VertexAOBaker.cs b/Editor/VertexAOBaker.cs index ed563848..f529c169 100644 --- a/Editor/VertexAOBaker.cs +++ b/Editor/VertexAOBaker.cs @@ -213,9 +213,14 @@ public static float[] FaceAreaCorrection( float largeThreshold = medianArea * 4f; int triCount = tris.Length / 3; + // Parallel.For's loopState.Stop() only prevents new iterations; the ones + // that already ran have written into correction/totalWeight. Applying + // that partial accumulation would brighten an essentially random subset + // of vertices, so record the cancellation and discard the whole pass. + bool cancelled = false; Parallel.For(0, triCount, (ti, loopState) => { - if (UvProgress.CancelRequested) { loopState.Stop(); return; } + if (UvProgress.CancelRequested) { cancelled = true; loopState.Stop(); return; } int t = ti * 3; int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; @@ -250,6 +255,7 @@ public static float[] FaceAreaCorrection( { if ((d & 63) == 0 && UvProgress.CancelRequested) { + cancelled = true; loopState.Stop(); return; } @@ -298,6 +304,14 @@ public static float[] FaceAreaCorrection( } }); + // Cancelled mid-pass: correction/totalWeight cover only the triangles + // that happened to finish, so return the input untouched. + if (cancelled) + { + UvtLog.Info("[Vertex AO] Face-area correction cancelled — AO left unchanged."); + return (float[])ao.Clone(); + } + // Apply corrections var correctedAO = (float[])ao.Clone(); int correctedCount = 0; diff --git a/Tests/Editor/VertexAOBakerBlurTests.cs b/Tests/Editor/VertexAOBakerBlurTests.cs index a5bbb8e1..c0ceef31 100644 --- a/Tests/Editor/VertexAOBakerBlurTests.cs +++ b/Tests/Editor/VertexAOBakerBlurTests.cs @@ -1,7 +1,6 @@ // VertexAOBakerBlurTests.cs — regression coverage for the AO blur passes: // optional mesh attributes and bounded work on dense/degenerate meshes. -using System.Diagnostics; using NUnit.Framework; using UnityEngine; @@ -73,12 +72,11 @@ public void BlurAO3D_DenseMesh_CompletesWithinWorkBudget() for (int i = 0; i < vertexCount; i++) ao[i] = i % 2; - var stopwatch = Stopwatch.StartNew(); + // The work budget is enforced by [Timeout] alone: a wall-clock + // assertion inside the test measures the CI runner, not the budget. float[] result = VertexAOBaker.BlurAO3D(ao, positions, 10, 1f, 0.01f); - stopwatch.Stop(); Assert.That(result, Has.Length.EqualTo(vertexCount)); - Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(20000)); } } } From 39565fc79e296d33689a12a78ee16c799e7ed367 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:05:05 +0000 Subject: [PATCH 66/76] fix(repack): report session contention as a result error, free the temp mesh (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AcquireNativeSession threw InvalidOperationException straight through the public RepackSingle / RepackMulti / RepackMultiAsync surface, while every caller (LightmapTransferTool's per-mesh loop, the repack tests) consumes the RepackResult.error contract — so a concurrent repack surfaced as an unhandled exception instead of a per-mesh failure. Return the busy state as RepackResult.error; RepackMultiCore stamps it on every mesh since nothing was packed. The release side is untouched: acquisition still happens outside the try, so a failed claim can never release someone else's session. RepackUv now destroys its temporary Instantiate copy in a finally. The failed -result path already destroyed it, but an exception escaping RepackSingle — including the one above — leaked the mesh into the editor session. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/XatlasRepack.cs | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index a6eaeb3b..ee5948a4 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -190,12 +190,19 @@ public static class XatlasRepack // while a background pack is still using it. static int s_nativeSessionInFlight; - static void AcquireNativeSession() + const string kSessionBusyError = + "An xatlas repack operation is already in progress."; + + /// + /// Claims the process-global atlas. Returns false when another repack + /// already holds it — the public entry points report that through + /// rather than an exception, because + /// every caller consumes the result-based contract. + /// + static bool TryAcquireNativeSession() { - if (System.Threading.Interlocked.CompareExchange( - ref s_nativeSessionInFlight, 1, 0) != 0) - throw new InvalidOperationException( - "An xatlas repack operation is already in progress."); + return System.Threading.Interlocked.CompareExchange( + ref s_nativeSessionInFlight, 1, 0) == 0; } static void ReleaseNativeSession() @@ -983,19 +990,24 @@ public static Vector2[] RepackUv(Mesh mesh, Vector2[] uv0, uint[] faceShellIds, opts.resolution = (uint)resolution; opts.padding = (uint)padding; opts.rotateCharts = rotate; - // Work on a temporary copy so original mesh is untouched + // Work on a temporary copy so original mesh is untouched. The copy is + // destroyed in a finally: the early-out path handled a failed result, + // but an exception escaping RepackSingle (native bridge, mesh access) + // used to leak the mesh into the editor session. var tmp = UnityEngine.Object.Instantiate(mesh); - tmp.name = mesh.name + "_repack_tmp"; - var result = RepackSingle(tmp, opts); - if (!result.ok) + try + { + tmp.name = mesh.name + "_repack_tmp"; + var result = RepackSingle(tmp, opts); + if (!result.ok) return null; + var uvOut = new List(); + tmp.GetUVs(1, uvOut); + return uvOut.ToArray(); + } + finally { UnityEngine.Object.DestroyImmediate(tmp); - return null; } - var uvOut = new List(); - tmp.GetUVs(1, uvOut); - UnityEngine.Object.DestroyImmediate(tmp); - return uvOut.ToArray(); } public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) @@ -1143,7 +1155,11 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) uint xatlasFaceCount = (uint)faceCount; // ── xatlas pipeline ── - AcquireNativeSession(); + if (!TryAcquireNativeSession()) + { + result.error = kSessionBusyError; + return result; + } try { XatlasNative.xatlasCreate(); @@ -1412,7 +1428,14 @@ static async Task RepackMultiCore(Mesh[] meshes, RepackOptions o var allUvFlat = new float[meshCount][]; // ── Single xatlas session for all meshes ── - AcquireNativeSession(); + if (!TryAcquireNativeSession()) + { + // Nothing was packed, so every mesh carries the same failure — + // the per-mesh loop in the tool logs and skips each of them. + for (int m = 0; m < meshCount; m++) + results[m].error = kSessionBusyError; + return results; + } try { XatlasNative.xatlasCreate(); From 62ac795754e114b043880342863d45e047e53026 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:06:04 +0000 Subject: [PATCH 67/76] fix(benchmark): decouple atlas utilization from the PNG snapshot budget (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordMesh read UV2 + triangles only while the 32-PNG cap had room, but that same data feeds atlasUtilization — a scored metric that BenchmarkSweep weights x100. Past the 33rd recorded mesh (or for any mesh over the PNG size limits) the metric silently recorded 0, so a large suite could crown a different sweep winner purely from recording order. Read the data for every row (still bounded by the existing mesh-size sanity limits) and apply the 32-snapshot cap only to what is retained for the PNG dump. The pngSnapshotsSkipped counter keeps its original meaning. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/BenchmarkRecorder.cs | 43 +++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index c9edb244..f2aae212 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -169,27 +169,34 @@ public void RecordMesh(MeshEntry entry) (entry.lodIndex == sourceLodIndex ? entry.repackedMesh : entry.transferredMesh) ?? entry.originalMesh; - Vector2[] uv2Snap = null; - int[] trisSnap = null; - if (snapshotMesh != null && pngSnapshotsCaptured < MaxPngSnapshots && - IsPngSnapshotWithinLimits(snapshotMesh)) + // UV2 + triangles feed two consumers with different budgets: + // * atlasUtilization, a scored metric — must be read for every row, + // otherwise rows past the PNG cap record 0 and BenchmarkSweep + // (which weights utilization x100) picks a winner based on which + // meshes happened to be recorded first. + // * the per-mesh PNG dump, which is diagnostic and stays capped. + // Reading is bounded by the same mesh-size sanity limits either way. + bool withinPngLimits = snapshotMesh != null && IsPngSnapshotWithinLimits(snapshotMesh); + Vector2[] uv2Data = null; + int[] trisData = null; + if (withinPngLimits) { var list = new System.Collections.Generic.List(); snapshotMesh.GetUVs(1, list); if (list.Count > 0) { - // Only a mesh that actually yielded UV2 data consumes the - // snapshot budget; GetUVs on a mesh without UV2 is cheap and - // must not starve later meshes that do have UV2. - pngSnapshotsCaptured++; - uv2Snap = list.ToArray(); - trisSnap = snapshotMesh.triangles; + uv2Data = list.ToArray(); + trisData = snapshotMesh.triangles; } } - else if (snapshotMesh != null) - { - pngSnapshotsSkipped++; - } + + // Only a mesh that actually yielded UV2 data consumes the snapshot + // budget; a mesh without UV2 has no PNG to draw and must not starve + // later meshes that do. "Skipped" keeps its old meaning: rejected by + // the size limits, or denied a PNG slot despite having UV2. + bool retainPng = uv2Data != null && pngSnapshotsCaptured < MaxPngSnapshots; + if (retainPng) pngSnapshotsCaptured++; + else if (snapshotMesh != null && (uv2Data != null || !withinPngLimits)) pngSnapshotsSkipped++; // Validation report can be stale: a mesh that failed transfer in // a later sweep cell would otherwise carry the previous cell's @@ -243,8 +250,8 @@ public void RecordMesh(MeshEntry entry) topologyFixed = tr?.topologyFixed ?? 0, topologyCapHit = tr?.topologyCapHit ?? false, - uv2Snapshot = uv2Snap, - trianglesSnapshot = trisSnap, + uv2Snapshot = retainPng ? uv2Data : null, + trianglesSnapshot = retainPng ? trisData : null, }; // atlasUtilization = sum of |triangle area| in UV2 space — true @@ -255,9 +262,9 @@ public void RecordMesh(MeshEntry entry) // any UV with sqrMagnitude near zero (excluding legitimate verts // at the atlas origin) and reported bbox area instead of true // coverage, so layouts touching (0,0) under-reported. - if (uv2Snap != null && uv2Snap.Length > 0 && trisSnap != null) + if (uv2Data != null && uv2Data.Length > 0 && trisData != null) { - rec.atlasUtilization = (float)XatlasRepack.ComputeUv2CoverageFraction(uv2Snap, trisSnap); + rec.atlasUtilization = (float)XatlasRepack.ComputeUv2CoverageFraction(uv2Data, trisData); } records.Add(rec); } From ed73bcc8fd096e9b85b71c9d1a6e7db966be6ddc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:07:19 +0000 Subject: [PATCH 68/76] fix(transfer): gate Apply UV2 on a repack that actually produced meshes (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExecRepackCoreImpl set ctx.HasRepack = true after the result loop regardless of outcome, and left each entry's previous repackedMesh in place when the new run failed. Since #170 the Apply UV2 section is drawn purely off that flag, so after a failed or cancelled repack the user could apply a stale result — or, with no prior repack, the original UV2 (GetResultMesh falls back to originalMesh). Now each run clears its entries' repack output up front (destroying the old mesh, which the success path used to overwrite and leak), and HasRepack is derived from whether any entry currently holds a repacked mesh. Deriving it rather than assigning false keeps per-mesh grouping correct: that path calls this method once per group, and a later failing group must not erase an earlier group's success. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 50c448c4..b902ce4f 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -2155,6 +2155,18 @@ async Task ExecRepackCoreImpl(List entries, bool useAsync) var meshCopies = new List(); foreach (var e in entries) { + // Drop the previous run's output before producing a new one: + // otherwise a failed re-run leaves a stale repackedMesh that + // Apply UV2 would happily write to the FBX (and the successful + // path used to overwrite the reference, leaking the old mesh). + if (e.repackedMesh != null) + { + UnityEngine.Object.DestroyImmediate(e.repackedMesh); + e.repackedMesh = null; + } + e.repackedAtlasWidth = 0; + e.repackedAtlasHeight = 0; + if (e.originalMesh == null) continue; var uv0 = e.originalMesh.uv; if (uv0 == null || uv0.Length == 0) { UvtLog.Warn("[Repack] " + e.renderer.name + ": no UV0"); continue; } @@ -2163,7 +2175,11 @@ async Task ExecRepackCoreImpl(List entries, bool useAsync) validEntries.Add(e); meshCopies.Add(cp); } - if (meshCopies.Count == 0) return; + if (meshCopies.Count == 0) + { + ctx.HasRepack = ctx.MeshEntries.Any(e => e.repackedMesh != null); + return; + } var opts = RepackOptions.Default; opts.resolution = resolvedResolution; @@ -2204,7 +2220,13 @@ async Task ExecRepackCoreImpl(List entries, bool useAsync) validEntries[i].repackedAtlasHeight = results[i].atlasHeight; } - ctx.HasRepack = true; + // HasRepack gates the Apply UV2 UI, so it must mean "a repacked mesh + // exists right now", not "a repack was attempted". Setting it + // unconditionally let a failed or cancelled run leave the button + // enabled, applying the original UV2 to the FBX. Derive it from the + // entries instead — per-mesh grouping calls this once per group, so a + // later failing group must not erase an earlier group's success. + ctx.HasRepack = ctx.MeshEntries.Any(e => e.repackedMesh != null); ctx.ClearAllCaches(); requestRepaint?.Invoke(); } From 043b0d2596fb269acc5257cf2a3f51f59db251fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:07:51 +0000 Subject: [PATCH 69/76] fix(native): validate the remaining collision ABI parameters (PR #191 review) ConvexDecomp_Compute checked the geometry arguments and clamped maxRecursionDepth, but cast maxHulls, resolution, maxVertsPerHull and minEdgeLength straight to uint32_t and fillMode straight to the FillMode enum. A negative int wraps to a huge unsigned value in V-HACD (a negative resolution becomes a multi-billion-voxel grid) and an out-of-range fillMode matches no enum case (confirmed 0..2 in third_party/VHACD.h). Reject all of them with the existing nullptr failure result, before CreateVHACD allocates anything. The editor-side ranges all remain valid. Checked with g++ -fsyntax-only -std=c++17 -I third_party (clean). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Native~/src/collision.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Native~/src/collision.cpp b/Native~/src/collision.cpp index e1a4346b..4c9e4932 100644 --- a/Native~/src/collision.cpp +++ b/Native~/src/collision.cpp @@ -63,6 +63,18 @@ EXPORT void* ConvexDecomp_Compute( return nullptr; } + // The remaining tunables are handed to V-HACD as uint32_t or as an enum. + // A negative int wraps to a huge unsigned value there — a negative + // resolution becomes a multi-billion-voxel grid, a negative maxHulls an + // effectively unbounded hull budget — and a fillMode outside the enum + // selects no case at all. Reject them at the ABI boundary; the editor-side + // ranges (maxHulls 1..64, resolution 10000..1000000, maxVertsPerHull + // 8..255, minEdgeLength 1..8, fillMode 0..2) all stay valid. + if (maxHulls <= 0 || resolution <= 0 || maxVertsPerHull <= 0 || minEdgeLength <= 0) + return nullptr; + if (fillMode < 0 || fillMode > 2) + return nullptr; + // Recursive splitting can create 2^depth intermediate hulls, followed by an // O(n²) merge-cost allocation. Treat the native ABI as a trust boundary so // callers cannot bypass the editor-side work-budget limit. From 6f98c590d0005ffe680b7462418bfbe05ce82807 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:08:10 +0000 Subject: [PATCH 70/76] fix(native): record the vendored xatlas upstream snapshot (PR #191 review) The xatlas sources under Native~/third_party/xatlas carried no provenance, so nothing recorded which upstream revision the shipped binaries were built from. Add a VERSION file naming the upstream repo, commit f700c77, the license and the snapshot/verification date, plus the rule that local changes go in the bridge rather than the snapshot. xatlas.h / xatlas.cpp are untouched, and Native~ is tilde-hidden so the file needs no .meta. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Native~/third_party/xatlas/VERSION | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Native~/third_party/xatlas/VERSION diff --git a/Native~/third_party/xatlas/VERSION b/Native~/third_party/xatlas/VERSION new file mode 100644 index 00000000..51ddcedc --- /dev/null +++ b/Native~/third_party/xatlas/VERSION @@ -0,0 +1,21 @@ +xatlas — vendored snapshot +========================== + +Upstream: https://github.com/jpcy/xatlas +Upstream commit: f700c77 +License: MIT (Jonathan Young) — see the header of xatlas.h +Snapshot taken: 2026-08-06 +Last verified: 2026-08-06 + +Vendored files +-------------- +xatlas.h +xatlas.cpp + +Rules +----- +These sources are an unmodified upstream snapshot. Do not patch them in place: +local fixes belong in Native~/xatlas-unity-bridge.cpp, so the snapshot stays +diffable against upstream. To move to a newer upstream revision, replace both +files wholesale, update the commit and both dates above, and rebuild the +binaries through the build-native workflow. From 388dcbd8629f546efd2677ba70e5ac5b76a3c626 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:10:21 +0000 Subject: [PATCH 71/76] fix(tools): validate the values gen.bat forwards to python (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed the #135 whitelist only constrains the option NAMES (%~2 / %~4). The values — %~1, %~3 and %~5 — went through untouched, and cmd.exe substitutes an argument into the python line before parsing that line, so a value carrying a double quote could close the quoting and run whatever followed it. Each forwarded value is now matched against a strict charset (letters, digits, _ - . ~ : \ / and space) and rejected with exit /b 3 otherwise. The test runs through delayed expansion, which substitutes the value after the line is parsed, so the value under test cannot itself be read as syntax. No cmd.exe in this environment, so the batch flow was desk-checked and the charset decisions were verified with the equivalent POSIX class: the example invocation, Windows drive paths and paths with spaces are accepted; quotes, & | < > ^ % ! ( ) ; and apostrophes are rejected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Tools~/gen.bat | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/Tools~/gen.bat b/Tools~/gen.bat index 144e4cca..87eba8b0 100644 --- a/Tools~/gen.bat +++ b/Tools~/gen.bat @@ -8,13 +8,34 @@ rem rem Example: rem Tools\gen.bat "_results~/noSymSplit_2026-04-28" --gallery-id "noSymSplit_2026-04-28" -setlocal +setlocal EnableExtensions EnableDelayedExpansion set "SCRIPT=%~dp0build_gallery.py" if not exist "%SCRIPT%" ( echo [gen.bat] Cannot find "%SCRIPT%" exit /b 1 ) +rem ── Validate the forwarded values ────────────────────────────────────── +rem The option whitelist below constrains only the option NAMES (%~2, %~4). +rem The values — %~1 (data folder), %~3 and %~5 (option arguments) — were +rem forwarded untouched, and cmd.exe substitutes them into the python line +rem before it parses that line, so a value carrying a double quote could +rem close the quoting and turn the rest into commands. +rem +rem Check each value against a strict charset: letters, digits, underscore, +rem dash, dot, tilde, colon, both path separators and space. Quotes and cmd +rem metacharacters (& | < > ^ % ! ( ) ; ,) are rejected. The test runs through +rem delayed expansion (!VAR!), which substitutes after the line has been +rem parsed, so the value under test cannot be read as syntax. +set "ARG1=%~1" +set "ARG3=%~3" +set "ARG5=%~5" +for %%V in (ARG1 ARG3 ARG5) do ( + if defined %%V ( + echo(!%%V!| findstr /r /c:"^[A-Za-z0-9_.~:/\\ -][A-Za-z0-9_.~:/\\ -]*$" >nul || goto badarg + ) +) + rem Forward only the argument forms supported by build_gallery.py. Expanding rem %%* here would cause cmd.exe to parse metacharacters in the original rem command line a second time. @@ -46,6 +67,11 @@ goto usage python "%SCRIPT%" "%~1" goto end +:badarg +echo [gen.bat] Rejected argument. The data folder and the --out / --gallery-id +echo [gen.bat] values may contain only letters, digits and _ - . ~ : \ / space. +exit /b 3 + :usage echo [gen.bat] Usage: gen.bat "data-folder" [--out "output-folder"] [--gallery-id "id"] exit /b 2 From 5eb9e3a0e913dd545b7b68cf84f79c2f0032f096 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:10:59 +0000 Subject: [PATCH 72/76] fix(preview): share the non-negative colour key helper (PR #191 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NonNegativeColorKey was duplicated verbatim in UvCanvasView (2D canvas) and ShellColorModelPreview (3D model preview) — the two systems must agree on how a shell hash maps to a palette slot, and a copy each is how they drift. One internal helper in Editor/UvHashUtil.cs (with .meta, fresh GUID) now serves both; the int.MinValue fold is documented in one place. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Framework/UvCanvasView.cs | 9 ++------- Editor/ShellColorModelPreview.cs | 11 +++-------- Editor/UvHashUtil.cs | 22 ++++++++++++++++++++++ Editor/UvHashUtil.cs.meta | 11 +++++++++++ 4 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 Editor/UvHashUtil.cs create mode 100644 Editor/UvHashUtil.cs.meta diff --git a/Editor/Framework/UvCanvasView.cs b/Editor/Framework/UvCanvasView.cs index 8e5c2c94..2cb16790 100644 --- a/Editor/Framework/UvCanvasView.cs +++ b/Editor/Framework/UvCanvasView.cs @@ -621,7 +621,7 @@ public void GlFillSh(UvToolContext ctx, float ox, float oy, float sz, Mesh mesh, { if (tot>=MAX_TRI) break; int colorKey = GetShellColorKey(ctx, s, entry); - Color c = pal[NonNegativeColorKey(colorKey) % pal.Length]; + Color c = pal[UvHashUtil.NonNegativeColorKey(colorKey) % pal.Length]; if (s.shellId == selectedShellId) c = Color.Lerp(c, Color.white, 0.45f); c.a = s.shellId == selectedShellId ? Mathf.Clamp01(FillAlpha * 1.85f) : FillAlpha; @@ -911,12 +911,7 @@ static int ShellBBoxHash(UvShell shell, Mesh mesh, int uvChannel = 1) int qw = Mathf.RoundToInt((mx.x - mn.x) * 100f); int qh = Mathf.RoundToInt((mx.y - mn.y) * 100f); int hash = unchecked((qx * 73856093) ^ (qy * 19349663) ^ (qw * 83492791) ^ (qh * 41729381)); - return NonNegativeColorKey(hash); - } - - static int NonNegativeColorKey(int colorKey) - { - return colorKey == int.MinValue ? int.MaxValue : Mathf.Abs(colorKey); + return UvHashUtil.NonNegativeColorKey(hash); } public int GetShellColorKey(UvToolContext ctx, UvShell shell, MeshEntry entry) diff --git a/Editor/ShellColorModelPreview.cs b/Editor/ShellColorModelPreview.cs index bea0b024..12fe2dc5 100644 --- a/Editor/ShellColorModelPreview.cs +++ b/Editor/ShellColorModelPreview.cs @@ -87,7 +87,7 @@ static int[] BuildTriangleShellIds(Mesh mesh) int qw = Mathf.RoundToInt((mx.x - mn.x) * 100f); int qh = Mathf.RoundToInt((mx.y - mn.y) * 100f); int hash = unchecked((qx * 73856093) ^ (qy * 19349663) ^ (qw * 83492791) ^ (qh * 41729381)); - int stableKey = NonNegativeColorKey(hash); + int stableKey = UvHashUtil.NonNegativeColorKey(hash); foreach (int faceIndex in shell.faceIndices) if (faceIndex >= 0 && faceIndex < triangleToShell.Length) @@ -104,11 +104,6 @@ static int[] BuildTriangleShellIds(Mesh mesh) public static bool IsActive => isActive; - static int NonNegativeColorKey(int colorKey) - { - return colorKey == int.MinValue ? int.MaxValue : Mathf.Abs(colorKey); - } - /// /// Apply with pre-computed face color keys (same logic as 2D preview). /// @@ -224,7 +219,7 @@ static Mesh BuildColorizedClone(Mesh sourceMesh, Color32[] palette, int[] faceCo int colorKey = (faceColorKeys != null && face < faceColorKeys.Length) ? faceColorKeys[face] : face; Color32 color = palette != null && palette.Length > 0 - ? palette[NonNegativeColorKey(colorKey) % palette.Length] + ? palette[UvHashUtil.NonNegativeColorKey(colorKey) % palette.Length] : new Color32(255, 255, 255, 255); int triBase = face * 3; @@ -265,7 +260,7 @@ static Mesh BuildColorizedClone(Mesh sourceMesh, Color32[] palette, PreviewShell { int shellId = shellIds[face]; Color32 color = palette != null && palette.Length > 0 - ? palette[NonNegativeColorKey(shellId) % palette.Length] + ? palette[UvHashUtil.NonNegativeColorKey(shellId) % palette.Length] : new Color32(255, 255, 255, 255); int triBase = face * 3; diff --git a/Editor/UvHashUtil.cs b/Editor/UvHashUtil.cs new file mode 100644 index 00000000..6764061a --- /dev/null +++ b/Editor/UvHashUtil.cs @@ -0,0 +1,22 @@ +// UvHashUtil.cs — Shared hashing helpers for shell/face colour assignment. +// Extracted from UvCanvasView and ShellColorModelPreview so the 2D canvas and +// the 3D model preview cannot drift apart on how a key maps to a palette slot. + +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class UvHashUtil + { + /// + /// Maps an arbitrary hash to a non-negative palette index source. + /// Mathf.Abs(int.MinValue) overflows back to int.MinValue, + /// which would produce a negative modulo and index outside the palette, + /// so that one value is folded to instead. + /// + internal static int NonNegativeColorKey(int colorKey) + { + return colorKey == int.MinValue ? int.MaxValue : Mathf.Abs(colorKey); + } + } +} diff --git a/Editor/UvHashUtil.cs.meta b/Editor/UvHashUtil.cs.meta new file mode 100644 index 00000000..25affd3c --- /dev/null +++ b/Editor/UvHashUtil.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00eb58faeb21489388ada2aec58c1690 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 2eb1a5e8d5b1d9821b574faf815449b346afee3b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:14:18 +0000 Subject: [PATCH 73/76] fix(native): rename xatlas VERSION file shadowing the C++ header The vendored xatlas directory is on the native include path; on case-insensitive filesystems (macOS/Windows) #include resolved to third_party/xatlas/VERSION and broke the build. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Native~/third_party/xatlas/{VERSION => UPSTREAM_VERSION.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Native~/third_party/xatlas/{VERSION => UPSTREAM_VERSION.txt} (100%) diff --git a/Native~/third_party/xatlas/VERSION b/Native~/third_party/xatlas/UPSTREAM_VERSION.txt similarity index 100% rename from Native~/third_party/xatlas/VERSION rename to Native~/third_party/xatlas/UPSTREAM_VERSION.txt From bf9013c4a907e47b11a1dd373af91fae8d222440 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:46:44 +0000 Subject: [PATCH 74/76] fix(export): destroy remaining temporary export meshes (leak cleanup) The LOD/group FBX export path created two more kinds of temporary meshes that were never released: the per-entry `exportMesh` clones (Instantiate of the result mesh, for both the replace-in-clone and the add-missing-LOD branches) and the `stripped` collision meshes rebuilt for every `_COL` node. DestroyImmediate on tempRoot only frees GameObjects, so both leaked on every export. Route them through the same sink + DestroyTempMeshes pattern introduced in 78a6e7a: the per-group list (renamed `bakedMeshes` -> `tempMeshes`, since it now also carries export clones and stripped collision meshes) collects each mesh right after it is created, and the existing finally block releases them after ModelExporter.ExportObjects has read their vertex data. Only meshes created here are destroyed. The export clones live solely on tempRoot and in a local dictionary; `stripped` copies srcCol's vertex and index data instead of aliasing it, so srcCol (which may be an FBX sub-asset) is untouched. Nothing downstream holds them: renameMap is string->string, RelinkSceneMeshReferences reloads meshes from the reimported FBX, and the sidecar path builds its own clone from resultMesh. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index b902ce4f..6f482997 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -3263,8 +3263,10 @@ void ExportFbx(bool overwriteSource) tempRoot.name = fbxPrefab.name; PromoteRootMeshToLod0Child(tempRoot); - // Mesh copies created while baking node transforms — destroyed after export. - var bakedMeshes = new List(); + // Temporary mesh copies built for this export group — export clones, + // transform-baked copies and stripped collision meshes. They only ever + // live on tempRoot, so they are destroyed once the export finished. + var tempMeshes = new List(); try { var lastLodRendererTemplate = FindLastLodRenderer(entries); @@ -3275,6 +3277,8 @@ void ExportFbx(bool overwriteSource) foreach (var (entry, resultMesh) in entries) { var exportMesh = UnityEngine.Object.Instantiate(resultMesh); + // Temporary copy — destroyed after the FBX export. + tempMeshes.Add(exportMesh); // Copy UV channels from fbxMesh first (base UVs), // then from originalMesh (has AO and other tool modifications). if (entry.fbxMesh != null) @@ -3353,6 +3357,8 @@ void ExportFbx(bool overwriteSource) } var newMf = child.AddComponent(); var exportMesh = UnityEngine.Object.Instantiate(resultMesh); + // Temporary copy — destroyed after the FBX export. + tempMeshes.Add(exportMesh); if (entry.fbxMesh != null) PreserveUvChannels(exportMesh, entry.fbxMesh); if (entry.originalMesh != null && entry.originalMesh != entry.fbxMesh) @@ -3459,7 +3465,7 @@ void ExportFbx(bool overwriteSource) // Ensure root is a clean pivot (identity transform, no mesh) // and LOD0 child named same as root gets _LOD0 suffix. // Returns a map of oldNodeName → newNodeName for mesh re-linking. - var nodeRenameMap = NormalizeExportHierarchy(tempRoot, bakedMeshes); + var nodeRenameMap = NormalizeExportHierarchy(tempRoot, tempMeshes); if (nodeRenameMap.Count > 0) meshRenamesByFbx[sourceFbxPath] = nodeRenameMap; @@ -3543,7 +3549,10 @@ void ExportFbx(bool overwriteSource) var srcCol = colMf.sharedMesh; if (srcCol.isReadable) { + // Owns copies of srcCol's data (SetVertices/SetTriangles copy), + // so it can be destroyed after the export without touching srcCol. var stripped = new Mesh { name = srcCol.name }; + tempMeshes.Add(stripped); stripped.SetVertices(srcCol.vertices); for (int s = 0; s < srcCol.subMeshCount; s++) stripped.SetTriangles(srcCol.GetTriangles(s), s); @@ -3622,7 +3631,7 @@ void ExportFbx(bool overwriteSource) finally { UnityEngine.Object.DestroyImmediate(tempRoot); - DestroyTempMeshes(bakedMeshes); + DestroyTempMeshes(tempMeshes); } // Restore isReadable if we changed it (non-overwrite path only; From b579d2f804be02e3978737ca7de90eee52e586df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:48:34 +0000 Subject: [PATCH 75/76] fix(export): destroy sidecar-built collision meshes after FBX export (leak cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CollisionMeshTool.GetCollisionMeshesFromSidecar allocates a fresh Mesh per hull from the sidecar's serialized position/index arrays. ExportFbx attached them to _COL nodes on tempRoot and then replaced them with the stripped copies, so they were unreachable but never released — a leak on every export of an FBX that has sidecar collision data. Append them to the same per-group tempMeshes sink at the point where they are attached, so the existing DestroyTempMeshes call in the finally block frees them after ModelExporter.ExportObjects. Collecting at the sidecar loop (not the strip loop) is what makes this safe: every mesh on both of that method's return paths comes from the single `new Mesh()` construction site, so nothing shared or asset-backed can enter the sink. The strip loop's srcCol is deliberately still not collected — for collision nodes that came from the source FBX rather than the sidecar it is a real FBX sub-asset. The same caller-owns-the-meshes contract is already assumed by VertexAOTool, which puts them in batch.temporaryMeshesToDestroy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- Editor/Tools/LightmapTransferTool.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 6f482997..520d860a 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -3485,6 +3485,10 @@ void ExportFbx(bool overwriteSource) int collisionMeshCount = 0; foreach (var (colMeshName, colMeshes, isConvex) in collisionData) { + // GetCollisionMeshesFromSidecar builds every one of these from the + // sidecar's serialized arrays — they are never FBX sub-assets, and + // the caller owns them. Destroyed after the export. + tempMeshes.AddRange(colMeshes); if (colMeshes.Count == 1 && !isConvex) { // Simplified: single _COL child (no MeshRenderer — avoids stale material) From 9625a695c3dbad9fa411b9cb7d69a3bb8f7fa94c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:55:45 +0000 Subject: [PATCH 76/76] fix(transfer): create analysis material folders only when debug is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BenchmarkRecorder.NewRun opened a recording session on every Run Full Pipeline / Repack / Transfer All click, so a production transfer run wrote /BenchmarkReports/ plus a per-mesh _png/ subfolder of CSV/JSON/PNG analysis artefacts that nobody asked for. Gate the session on MeshLabProjectSettings.showDebugUI — the flag that already hides Parameter Sweep, Log filters, UV0 Analysis & Fix, the Repack "Advanced (debug)" block, the Mesh Lab ▸ Export FBX Metrics menu items and the Sweep Test Suite create action. With the flag off NewRun returns the existing NoOpScope, Current stays null and no folder or file is produced; every consumer already guards on `Current != null` / `_bench is BenchmarkRecorder`, so nothing logs or throws. Sweep and the FBX metrics exporter are reachable only from debug-gated UI, so they keep working unchanged. Existing report folders on disk are untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0195YC4HWo1rQqEdFBb8p8jE --- CHANGELOG.md | 1 + Documentation~/TRANSFER_BENCHMARK.md | 6 ++++-- Editor/BenchmarkRecorder.cs | 10 ++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 012777b8..8e6f4127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Repack tab: the `Advanced (debug)` section (manual texels-per-UV-unit, post-pack density correction, SymSplit threshold mode). - Unity top menu: `Mesh Lab ▸ Export FBX Metrics (Selected Assets)` and `(Scene LODGroup)`. - Assets ▸ Create context menu: `Mesh Lab ▸ Sweep Test Suite` (was always-visible `Lightmap UV Tool/Test Suite` via `[CreateAssetMenu]`). + - On-disk transfer-analysis artefacts: `BenchmarkRecorder.NewRun` returns a no-op scope when the toggle is off, so Run Full Pipeline / Repack / Transfer All no longer create `/BenchmarkReports/` and its per-mesh `*_png/` subfolder on a production run. Existing report folders on disk are left untouched. - **In-flight gate + `FireAndForget` helper** for fire-and-forget async UI actions. The "Run Full Pipeline" / "Run Repack only" / "Run Transfer only" / "Repack All" / "Transfer All Targets" buttons now sit inside an `EditorGUI.DisabledScope` on `_pipelineInFlight` so a second click can't launch an interleaving run; `FireAndForget` attaches `ContinueWith` on the Unity sync context to log Task faults through `UvtLog.Error`, release the gate, and call `UvProgress.Fail` so a thrown exception can't leave the strip stuck on a stale phase. - **Cancel-aware benchmark recording** in `ExecTransferAllImpl` — mirrors the `completedSuccessfully` guard `ExecFullPipelineImpl` already uses. Cancelled transfers no longer emit stale `shellTransferResult` / validation rows that taint sweep aggregates. diff --git a/Documentation~/TRANSFER_BENCHMARK.md b/Documentation~/TRANSFER_BENCHMARK.md index f783590a..53fd4d89 100644 --- a/Documentation~/TRANSFER_BENCHMARK.md +++ b/Documentation~/TRANSFER_BENCHMARK.md @@ -67,7 +67,7 @@ strip-parameterization. | Piece | Location | What it does | | --- | --- | --- | | `UvtLog.Category` | `Editor/UvtLog.cs` | Per-subsystem log filter. Toggle in *Pipeline Settings → Log filters*. | -| `BenchmarkRecorder` | `Editor/BenchmarkRecorder.cs` | Collects per-mesh metrics during `ExecFullPipeline` / `ExecTransferAll`; writes CSV + JSON into `/BenchmarkReports/` on session end. | +| `BenchmarkRecorder` | `Editor/BenchmarkRecorder.cs` | Collects per-mesh metrics during `ExecFullPipeline` / `ExecTransferAll`; writes CSV + JSON into `/BenchmarkReports/` on session end. Only records when *Project Settings ▸ Mesh Lab ▸ Developer ▸ Show Debug UI* is on — with the toggle off `NewRun` returns a no-op scope and no report folder is created. | | `SymmetrySplitShells.LastFallbackCount` / `LastTotalSplitCount` | `Editor/SymmetrySplitShells.cs` | Counters read by the recorder. | | `GroupedShellTransfer.LastTopologyIterations` / `LastTopologyFixed` / `LastTopologyCapHit` | `Editor/GroupedShellTransfer.cs` | Counters for the Laplacian topology pass. | | `UvCanvasView.ValidationFilterMask` | `Editor/Framework/UvCanvasView.cs` | Restricts the validation fill/overlay to selected `TriIssue` bits. | @@ -112,7 +112,9 @@ JSON output mirrors the CSV but nests `records[]` inside a run envelope. - `Per-mesh repack` on/off - `SymSplit target LODs (advanced)` on/off -3. **Run the pipeline.** Click *Run Full Pipeline*. `BenchmarkRecorder` wraps +3. **Run the pipeline.** Enable *Project Settings ▸ Mesh Lab ▸ Developer ▸ Show + Debug UI* first — recording is skipped entirely while it is off. Then click + *Run Full Pipeline*. `BenchmarkRecorder` wraps the call, writes `/BenchmarkReports/{ts}_{lodGroup}_FullPipeline_{mode}.{csv,json}` when the run finishes. diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index f2aae212..7dc277d4 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -106,9 +106,19 @@ public sealed class BenchmarkRecorder : IDisposable /// captures everything and the inner caller gets a scope whose Dispose does nothing. /// Always call inside `using (BenchmarkRecorder.NewRun(...)) { ... }`. /// + /// + /// Recording is a diagnostic surface, so it is skipped entirely unless + /// Project Settings ▸ Mesh Lab ▸ Show Debug UI is on — the same flag that + /// gates Parameter Sweep, the FBX metrics menu items and the rest of the + /// transfer diagnostics. With the flag off the caller gets a no-op scope, + /// stays null and no BenchmarkReports/ folder (nor its + /// per-mesh PNG subfolder) is created for a production transfer run. + /// Existing report folders on disk are left untouched. + /// public static IDisposable NewRun(UvToolContext ctx, string label, bool splitTargets, SymmetrySplitShells.ThresholdMode symMode) { + if (!MeshLabProjectSettings.Instance.showDebugUI) return NoOpScope.Instance; if (Current != null) return NoOpScope.Instance; Current = new BenchmarkRecorder(ctx, label, splitTargets, symMode); return Current;