diff --git a/.claude/skills/repo-conventions/SKILL.md b/.claude/skills/repo-conventions/SKILL.md index 1f336fe1..4944cf8c 100644 --- a/.claude/skills/repo-conventions/SKILL.md +++ b/.claude/skills/repo-conventions/SKILL.md @@ -31,7 +31,8 @@ Canonical conventions for the `UnityMeshLab` repository. This file overrides `_s Located under `.github/workflows/`: - `build-native.yml` — builds native plugin binaries (platform-specific). -- `meta-check.yml` — verifies `.meta` file coverage. +- `meta-check.yml` — *Unity Package Checks*: `.meta` file coverage, `package.json`, the CS0103 identifier heuristic, and the licence-free C# compile of `Editor/` + `Tests/` (`Tools~/compile_check.py`, .NET SDK against the NuGet Unity reference assemblies, both FBX define variants). +- `sonar-static-analysis.yml` — *SonarQube*: the compile-check build analysed with `dotnet-sonarscanner` on the self-hosted server (Community edition): `main` into the project itself with the quality gate enforced, a PR into a per-run throwaway project whose issues `Tools~/sonar-pr-check.mjs` filters to the PR's changed lines (any new finding reds the check, except the advisory rules S3776/S3267/S3358/S1075, reported only; S125/S1104/S107/S1168 are off in the begin step with their reasons). `sonar-backlog-report.yml` is the manual read-only triage report; `Tools~/sonar-mcp.sh` the read-only MCP server. Needs the `SONAR_TOKEN` secret; skips with a notice without it. Runbook: `the Sonar runbook in SashaRX/Space (docs/sonar-autofix.md; here the tools live in Tools~/ and the config in ~/.config/meshlab/)`. - `version-bump.yml` — automates `package.json` version bumps. - `test.yml` — EditMode test run on Unity 6000.0. License-gated: skips cleanly when no `UNITY_LICENSE`/`UNITY_SERIAL` secrets are configured. **This repository is on Unity Personal (free) tier**, and Unity disabled manual `.alf`→`.ulf` activation for Personal seats in 2024, so the test job is currently always **skipped** on GitHub-hosted runners. Local Test Runner remains the canonical pre-commit verification path. See `unity-ci-validation/SKILL.md` §License activation for the recipe and the path forward (self-hosted runner or Plus/Pro upgrade). - `release.yml` — tag-triggered GitHub Release; verifies `v` tag matches `package.json` and extracts the matching section from `CHANGELOG.md`. diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 20054b88..7cdf3d8c 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -44,11 +44,14 @@ jobs: persist-credentials: false - name: Configure CMake - run: cmake -S 'Native~' -B build ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release + run: cmake -S 'Native~' -B build ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release -DMESHLAB_BUILD_TESTS=ON - name: Build run: cmake --build build --config Release + - name: Test remesh bridge + run: ctest --test-dir build -C Release --output-on-failure + - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.github/workflows/meta-check.yml b/.github/workflows/meta-check.yml index 0b71d8da..40749148 100644 --- a/.github/workflows/meta-check.yml +++ b/.github/workflows/meta-check.yml @@ -24,6 +24,9 @@ on: - 'Tools~/**' - '*.md' - 'package.json' + # Manual re-run on any branch (a GITHUB_TOKEN push by a workflow starts no + # pull_request run of its own, so a bot commit needs this to get its checks). + workflow_dispatch: jobs: meta-files: @@ -55,10 +58,18 @@ jobs: EXIT=1 fi done - # Check all tracked files in Editor/, Plugins/, Shaders/ that need .meta. - # Native~/ and Documentation~/ are hidden from Unity by the ~ suffix - # and do NOT need .meta files. - for f in $(find Editor Plugins Shaders -type f \ + # Any other tracked file at the package root is imported too (a stray + # screenshot once shipped this way); dotfiles are the only exception. + for f in $(git ls-files | grep -v '/' | grep -v '\.meta$' | grep -v '^\.'); do + if [ ! -f "${f}.meta" ]; then + echo "::error::Missing .meta file for package-root file: ${f}" + EXIT=1 + fi + done + # Check all tracked files in Editor/, Plugins/, Shaders/, Tests/ that + # need .meta. Native~/, Documentation~/ and Tools~/ are hidden from + # Unity by the ~ suffix and do NOT need .meta files. + for f in $(find Editor Plugins Shaders Tests -type f \ ! -name '*.meta' \ ! -name '.gitkeep' \ ! -name '.DS_Store' \ @@ -69,7 +80,7 @@ jobs: fi done # Check for orphaned .meta files (no matching file/dir) - for m in $(find Editor Plugins Shaders -name '*.meta' 2>/dev/null); do + for m in $(find Editor Plugins Shaders Tests -name '*.meta' 2>/dev/null); do target="${m%.meta}" if [ ! -e "$target" ]; then echo "::warning::Orphaned .meta file (no matching file/dir): ${m}" @@ -136,3 +147,30 @@ jobs: - name: Check for identifiers used but never declared run: python3 'Tools~/check_undeclared_identifiers.py' Editor Tests + + csharp-compile: + name: Compile C# against Unity reference assemblies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('Tools~/compile_check.py') }} + + # The whole package type-checks here with the .NET SDK — no Unity licence — + # against the UnityEngine/UnityEditor reference assemblies from NuGet, once + # without and once with LIGHTMAP_UV_TOOL_FBX_EXPORTER. This is what the + # identifier heuristic above approximates; a real CS error of any kind fails + # the job with a file/line annotation. The four 2021-era API gaps the + # references have are bridged on copied sources (see the script header). + - name: Build Editor and Tests (both FBX define variants) + run: python3 'Tools~/compile_check.py' diff --git a/.github/workflows/sonar-backlog-report.yml b/.github/workflows/sonar-backlog-report.yml new file mode 100644 index 00000000..3fb1de79 --- /dev/null +++ b/.github/workflows/sonar-backlog-report.yml @@ -0,0 +1,47 @@ +# Sonar Backlog Report — a READ-ONLY snapshot of the main project on the self-hosted +# SonarQube: every open issue grouped by rule, the security hotspots still to review, +# and how many issues are already Accepted / False positive. It is the triage input +# for deciding, rule by rule, fix / exclude in configuration / Accept / False positive. +# Nothing on the server or in the repo is changed: the job only reads the web API +# (Tools~/sonar-pr-check.mjs report) and uploads report.md + the full JSON lists. +# Modelled on SashaRX/Space's sonar-backlog-report.yml; the Sonar runbook in SashaRX/Space (docs/sonar-autofix.md; here the tools live in Tools~/ and the config in ~/.config/meshlab/) +# is the runbook. + +name: Sonar Backlog Report + +# Manual dispatch only: the job holds the Sonar user token, so it runs when someone with +# write access starts it, never because a branch was pushed. +on: + workflow_dispatch: + +permissions: {} + +env: + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL || vars.SONAR_HOST_URL || 'http://178.104.156.174:9000' }} + SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || 'SashaRX_UnityMeshLab' }} + +jobs: + report: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Read the open issues and hotspots + env: + # Web API reads need a USER token — see Tools~/sonar-pr-check.mjs. + SONAR_TOKEN: ${{ secrets.SONAR_API_TOKEN || secrets.SONAR_TOKEN }} + run: | + node Tools~/sonar-pr-check.mjs report --project "$SONAR_PROJECT_KEY" \ + --out-dir "$RUNNER_TEMP/sonar-backlog-report" + + - name: Upload the report + uses: actions/upload-artifact@v4 + with: + name: sonar-backlog-report + path: ${{ runner.temp }}/sonar-backlog-report + retention-days: 14 diff --git a/.github/workflows/sonar-static-analysis.yml b/.github/workflows/sonar-static-analysis.yml new file mode 100644 index 00000000..7b14e5f8 --- /dev/null +++ b/.github/workflows/sonar-static-analysis.yml @@ -0,0 +1,444 @@ +name: SonarQube + +# Static analysis of the package's C# on the self-hosted SonarQube Community Build, +# modelled on SashaRX/Space's sonar-static-analysis.yml. +# +# Community Build has no branch/PR analysis (no branch plugin, no PR decoration): a +# scan without a branch parameter REPLACES the project's single branch, so a PR scan +# into the main key would overwrite main's dashboard. Hence: +# +# push to main → the project itself (SONAR_PROJECT_KEY) with the Quality Gate +# ENFORCED (sonar.qualitygate.wait=true — a red gate reds the job). +# pull_request → a THROWAWAY project _pr_r_ (unique per +# run, so a cancelled run's cleanup never deletes a newer run's +# project). Tools~/sonar-pr-check.mjs keeps the OPEN issues on the +# lines the PR adds or modifies — the server's new-code period +# plays no part — publishes them (annotations, job summary, the +# `sonar-pr-findings-` artifact), deletes the project, and +# gates: ANY new finding on a changed line reds the check, like +# the server gate's new_violations > 0 — except the ADVISORY rules +# (Tools~/sonar-pr-check.mjs: S3776, S3267, S3358, S1075), reported but +# not gating, and the rules switched off in the begin step below. +# A PR that changes the Sonar workflows or tools checks every open +# PR instead of only itself (the tooling is proven on the queue; +# only the triggering PR's own findings gate that run). +# workflow_dispatch → `prs` = "open" (every open PR), PR numbers, or "main". +# +# The findings bundle each PR check uploads (`sonar-pr-findings-`: findings.json, +# prompt.md, selected.txt, summary.md) is what an autofix step would consume; none is +# wired here. The Sonar runbook in SashaRX/Space (docs/sonar-autofix.md) describes that +# loop; here the tools live in Tools~/ and the config in ~/.config/meshlab/. +# +# The C# build is the licence-free one from Tools~/compile_check.py, taken from THIS +# workflow's ref (an older PR may not carry the script): sources are copied into a +# build directory outside the checkout (reference-gap substitutions keep line numbers), +# so issues carry the repo-relative path and line; SCM blame is off for that reason. +# +# Secrets / variables (repository settings): +# SONAR_TOKEN — secret, the scanner's token. Throwaway projects are created on +# first analysis, so it needs Execute Analysis + Create Projects. +# SONAR_API_TOKEN — secret, optional: a USER token (squ_…) for the web API reads and +# the project deletes when SONAR_TOKEN is analysis-only. +# SONAR_HOST_URL — secret or variable, optional; defaults to the Mesh Lab server below. +# SONAR_PROJECT_KEY — variable, optional; defaults to SashaRX_UnityMeshLab. +on: + push: + branches: [main] + paths: + - 'Editor/**' + - 'Tests/**' + - 'Tools~/compile_check.py' + - 'Tools~/sonar-pr-check.mjs' + - '.github/workflows/sonar-static-analysis.yml' + pull_request: + paths: + - 'Editor/**' + - 'Tests/**' + - 'Tools~/compile_check.py' + - 'Tools~/sonar-*' + - '.github/workflows/sonar-*.yml' + workflow_dispatch: + inputs: + prs: + description: 'What to analyse: "open" (every open PR), PR numbers separated by commas, or "main"' + default: 'open' + required: true + +# Nothing at the workflow level: every job declares its own. +permissions: {} + +concurrency: + group: sonar-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL || vars.SONAR_HOST_URL || 'http://178.104.156.174:9000' }} + SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || 'SashaRX_UnityMeshLab' }} + +jobs: + plan: + name: Decide what to analyse + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + actions: read + outputs: + has-token: ${{ steps.probe.outputs.has-token }} + matrix: ${{ steps.targets.outputs.matrix }} + pr-matrix: ${{ steps.targets.outputs.pr-matrix }} + has-prs: ${{ steps.targets.outputs.has-prs }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + sparse-checkout: | + Tools~/sonar-pr-check.mjs + sparse-checkout-cone-mode: false + + - name: Probe token + id: probe + env: + HAS_TOKEN: ${{ secrets.SONAR_TOKEN != '' }} + run: | + echo "has-token=$HAS_TOKEN" >> "$GITHUB_OUTPUT" + if [ "$HAS_TOKEN" != "true" ]; then + echo "::notice::SONAR_TOKEN is not configured — add a SonarQube token as a repository secret to enable the scan." + echo "### SonarQube scan skipped" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "No \`SONAR_TOKEN\` secret. Create a token on the server (My Account ▸ Security) and add it under Settings ▸ Secrets ▸ Actions — the Sonar runbook in SashaRX/Space (docs/sonar-autofix.md; here the tools live in Tools~/ and the config in ~/.config/meshlab/)." >> "$GITHUB_STEP_SUMMARY" + fi + + # One matrix entry per analysis: the Sonar project key, the commit to analyse and, + # for a PR, its base (the diff base), head ref (the autofix push target), whether + # it is same-repo (autofix eligibility) and whether its findings gate this run. + - name: Collect targets + id: targets + if: steps.probe.outputs.has-token == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + THIS_PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.sha }} + INPUT_PRS: ${{ github.event.inputs.prs }} + run: | + set -euo pipefail + pr_entry() { + gh pr view "$1" --json number,headRefOid,baseRefOid,headRefName,baseRefName,isCrossRepository \ + --jq "{key: \"${SONAR_PROJECT_KEY}_pr\(.number)_r${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}\", label: \"PR #\(.number) \(.headRefName) → \(.baseRefName)\", head: .headRefOid, base: .baseRefOid, pr: (.number|tostring), head_ref: .headRefName, same_repo: (.isCrossRepository|not)}" \ + | jq -c --arg event "$EVENT" --arg this "$THIS_PR" '. + {gate: (if $event == "pull_request" then (.pr == $this) else true end)}' + } + open_prs() { gh pr list --state open --limit 100 --json number --jq '.[].number'; } + main_entry() { printf '{"key":"%s","label":"main","head":"%s","base":"","pr":"","head_ref":"","same_repo":false,"gate":true}\n' "$SONAR_PROJECT_KEY" "$HEAD_SHA"; } + : > targets.jsonl + case "$EVENT" in + push) main_entry > targets.jsonl ;; + workflow_dispatch) + if [ "$INPUT_PRS" = "main" ]; then main_entry > targets.jsonl + elif [ "$INPUT_PRS" = "open" ]; then for n in $(open_prs); do pr_entry "$n" >> targets.jsonl; done + else for n in $(echo "$INPUT_PRS" | tr ',' ' '); do pr_entry "$n" >> targets.jsonl; done + fi ;; + pull_request) + if gh pr diff "$THIS_PR" --name-only | grep -Eq '^(\.github/workflows/sonar-.*\.yml|Tools~/sonar-.*)$'; then + echo "::notice::This PR changes the SonarQube workflows or tools — checking every open PR with them." + for n in $(open_prs); do pr_entry "$n" >> targets.jsonl; done + else + pr_entry "$THIS_PR" > targets.jsonl + fi ;; + esac + jq -c -s '{include: .}' targets.jsonl > matrix.json + jq -c -s '{include: map(select(.pr != "" and .same_repo))}' targets.jsonl > pr-matrix.json + { + echo "matrix=$(cat matrix.json)" + echo "pr-matrix=$(cat pr-matrix.json)" + echo "has-prs=$(jq -r 'if (.include | length) > 0 then "true" else "false" end' pr-matrix.json)" + } >> "$GITHUB_OUTPUT" + echo "Targets:"; jq -r '.include[] | " \(.label) \(.key) gate=\(.gate)"' matrix.json + + # A run cancelled between its scan and its delete step leaves its throwaway project + # on the server. Every run lists the repository's throwaway projects and deletes + # those whose GitHub run is over; a run still going keeps its project. + - name: Delete orphan throwaway projects + if: steps.probe.outputs.has-token == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_API_TOKEN: ${{ secrets.SONAR_API_TOKEN }} + run: | + set -uo pipefail + if ! node Tools~/sonar-pr-check.mjs throwaways --prefix "$SONAR_PROJECT_KEY" > throwaways.txt; then + echo "::warning::Could not list the throwaway projects; skipping the orphan cleanup." + exit 0 + fi + orphans=() + while IFS= read -r key; do + [ -n "$key" ] || continue + run="${key##*_r}"; run="${run%_*}" + [ "$run" = "$GITHUB_RUN_ID" ] && continue + status="$(gh run view "$run" --json status --jq .status 2>/dev/null || echo gone)" + case "$status" in + in_progress|queued|waiting|requested|pending) echo " keeping $key (run $run is $status)" ;; + *) orphans+=("$key") ;; + esac + done < throwaways.txt + if [ "${#orphans[@]}" -eq 0 ]; then echo "No orphan throwaway projects."; exit 0; fi + args=(); for key in "${orphans[@]}"; do args+=(--project "$key"); done + node Tools~/sonar-pr-check.mjs delete "${args[@]}" + + scan: + name: ${{ matrix.label }} + needs: plan + if: needs.plan.outputs.has-token == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 2 + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + env: + KEY: ${{ matrix.key }} + LABEL: ${{ matrix.label }} + HEAD: ${{ matrix.head }} + BASE: ${{ matrix.base }} + PR: ${{ matrix.pr }} + steps: + # The workflow's own ref carries the build and check tools; the analysed commits may not. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + path: tooling + sparse-checkout: | + Tools~/compile_check.py + Tools~/sonar-pr-check.mjs + Tools~/sonar-pr-check.test.mjs + .github/workflows/sonar-static-analysis.yml + sparse-checkout-cone-mode: false + + # Full history: the PR's merge base must be present for the diff. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + path: src + ref: ${{ matrix.head }} + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Cache NuGet packages and the scanner + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + ~/.sonar/cache + key: sonar-${{ runner.os }}-${{ hashFiles('tooling/Tools~/compile_check.py', 'tooling/.github/workflows/sonar-static-analysis.yml') }} + + - name: Self-test the findings tool + run: node --test tooling/Tools~/sonar-pr-check.test.mjs + + - name: Install dotnet-sonarscanner + run: dotnet tool install --global dotnet-sonarscanner --version 11.3.0 + + # Prepare the licence-free build of the analysed checkout into a directory outside + # it, then begin / build / end in that directory (begin sets the MSBuild targets up + # for this shell only). Main waits for the gate; a PR's gate is the step below. + - name: Analyse + id: scan + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + set -euo pipefail + out="$RUNNER_TEMP/compile-check" + python3 tooling/Tools~/compile_check.py --prepare-only --root src --out "$out" + version="$(jq -r .version src/package.json)+${HEAD:0:7}" + if [ -n "$PR" ]; then + NAME="Mesh Lab - PR #$PR (throwaway)"; QG_WAIT=false + else + NAME="Mesh Lab"; QG_WAIT=true + fi + # Rules switched off as a whole, decided once here rather than issue by issue + # (Tools~/sonar-pr-check.mjs holds the ADVISORY rules, reported but not gating): + # S125 commented-out code — matches this repo's prose comments (formulae, + # operator names, the pipeline's step-by-step notes). + # S1104 public fields — the tool context, settings and recorder rows are + # plain data holders in the Unity editor style. + # S107 too many parameters — the geometry math takes vectors as scalars on + # purpose (no per-call allocation). + # S1168 return null instead of an empty collection — null means "absent" + # (no such UV channel, no colours, encode failed) throughout the code + # base and every caller tests for it; an empty collection would erase + # the difference between "none" and "empty". + # S1215 GC.Collect — in Editor/Bench only: the sweep collects between cells so + # the next cell's memory and timing do not carry the last one's garbage; + # everywhere else the rule stays on. + IGNORE_IDS="s125,s1104,s107,s1168,s1215" + IGNORE_ARGS="" + for id in s125 s1104 s107 s1168; do + IGNORE_ARGS="$IGNORE_ARGS /d:sonar.issue.ignore.multicriteria.$id.ruleKey=csharpsquid:${id^^} /d:sonar.issue.ignore.multicriteria.$id.resourceKey=**/*" + done + IGNORE_ARGS="$IGNORE_ARGS /d:sonar.issue.ignore.multicriteria.s1215.ruleKey=csharpsquid:S1215 /d:sonar.issue.ignore.multicriteria.s1215.resourceKey=Editor/Bench/**" + cd "$out" + # IGNORE_ARGS is deliberately word-split into flags. + # shellcheck disable=SC2086 + dotnet sonarscanner begin \ + /k:"$KEY" \ + /n:"$NAME" \ + /v:"$version" \ + /d:sonar.host.url="$SONAR_HOST_URL" \ + /d:sonar.token="$SONAR_TOKEN" \ + /d:sonar.scm.disabled=true \ + /d:sonar.exclusions="Shims/**" \ + /d:sonar.cs.file.suffixes=".cs" \ + /d:sonar.issue.ignore.multicriteria="$IGNORE_IDS" \ + $IGNORE_ARGS \ + /d:sonar.qualitygate.wait="$QG_WAIT" + dotnet build MeshLab.sln -nologo -v q --no-incremental -p:MeshLabFbx=true + dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN" 2>&1 | tee "$RUNNER_TEMP/sonar-end.log" + # The server processes the report asynchronously; the PR check waits on this + # compute-engine task before it reads any issue. + TASK="$(find .sonarqube -name report-task.txt -exec grep -h '^ceTaskId=' {} + 2>/dev/null | head -n 1 | cut -d= -f2)" + if [ -z "$TASK" ]; then + TASK="$(grep -oE 'api/ce/task\?id=[A-Za-z0-9_-]+' "$RUNNER_TEMP/sonar-end.log" | tail -n 1 | cut -d= -f2)" + fi + echo "ce_task=$TASK" >> "$GITHUB_OUTPUT" + + # Web API reads and the project delete try SONAR_API_TOKEN, then SONAR_TOKEN + # (Tools~/sonar-pr-check.mjs): they need a USER token, which either may be. + - name: Wait for the server to process the scan + id: wait + if: matrix.pr != '' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_API_TOKEN: ${{ secrets.SONAR_API_TOKEN }} + TASK: ${{ steps.scan.outputs.ce_task }} + run: | + if [ -z "$TASK" ]; then + echo "::error::The scan reported no compute-engine task id." + exit 1 + fi + node tooling/Tools~/sonar-pr-check.mjs wait --task-id "$TASK" + + # The diff the scan saw: merge-base(base, head)..head of the PR branch itself. + - name: Findings on the lines this PR changes + id: findings + if: steps.wait.outcome == 'success' + working-directory: src + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_API_TOKEN: ${{ secrets.SONAR_API_TOKEN }} + run: | + set -euo pipefail + MERGE_BASE="$(git merge-base "$BASE" "$HEAD")" + node ../tooling/Tools~/sonar-pr-check.mjs pr-findings \ + --project "$KEY" --base "$MERGE_BASE" --head "$HEAD" --pr "$PR" --max 12 \ + --out-dir "$RUNNER_TEMP/sonar-pr-findings" + + # Always uploaded for a PR (an empty bundle too): the autofix job reads it. + - name: Upload findings for the autofix loop + if: steps.findings.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: sonar-pr-findings-${{ matrix.pr }} + path: ${{ runner.temp }}/sonar-pr-findings + retention-days: 7 + + - name: Delete the throwaway PR project + if: always() && matrix.pr != '' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_API_TOKEN: ${{ secrets.SONAR_API_TOKEN }} + run: node tooling/Tools~/sonar-pr-check.mjs delete --project "$KEY" + + - name: Gate + if: always() && matrix.pr != '' + env: + SCAN: ${{ steps.scan.outcome }} + FINDINGS: ${{ steps.findings.outcome }} + TOTAL: ${{ steps.findings.outputs.total }} + SEVERE: ${{ steps.findings.outputs.severe }} + ADVISORY: ${{ steps.findings.outputs.advisory }} + GATES: ${{ matrix.gate }} + run: | + if [ "$FINDINGS" != "success" ]; then + echo "::error::The Sonar PR check did not complete (scan: $SCAN) — see the steps above." + exit 1 + fi + if [ "${TOTAL:-0}" != "0" ]; then + if [ "$GATES" = "true" ]; then + echo "::error::$TOTAL new gating Sonar finding(s) on lines this PR changes ($SEVERE severe, ${ADVISORY:-0} advisory besides) — see the job summary." + exit 1 + fi + echo "::warning::$LABEL: $TOTAL new gating Sonar finding(s) on its changed lines ($SEVERE severe, ${ADVISORY:-0} advisory besides); that PR's own check gates it." + exit 0 + fi + echo "Sonar PR check passed: no new gating findings on the lines this PR changes (${ADVISORY:-0} advisory)." + + # WHY the gate said what it said (main only): the per-condition verdicts. + - name: Summarize the main analysis + if: always() && matrix.pr == '' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_API_TOKEN: ${{ secrets.SONAR_API_TOKEN }} + run: | + { + echo "## SonarQube — main" + echo "" + echo "[$KEY]($SONAR_HOST_URL/dashboard?id=$KEY) — analysed \`${HEAD:0:7}\`, Quality Gate enforced (a red gate reds this job)." + echo "" + echo '```json' + curl -sS -m 15 -H @<(printf 'Authorization: Bearer %s\n' "$SONAR_TOKEN") \ + "$SONAR_HOST_URL/api/qualitygates/project_status?projectKey=$KEY" || echo '{"error":"gate status not read"}' + echo "" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # One table for the whole run: every checked PR with its gating / severe / advisory + # counts and the gating findings themselves, from the bundles the scan jobs uploaded. + summary: + name: Findings across the checked PRs + needs: [plan, scan] + if: always() && needs.plan.outputs.has-prs == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/download-artifact@v4 + with: + pattern: sonar-pr-findings-* + path: ${{ runner.temp }}/findings + + - name: Tabulate + run: | + set -euo pipefail + { + echo "## SonarQube — findings on the changed lines of every checked PR" + echo "" + echo "| PR | Gating | Severe | Advisory |" + echo "|---|---|---|---|" + for d in "$RUNNER_TEMP"/findings/sonar-pr-findings-*/; do + [ -f "$d/findings.json" ] || continue + n="$(basename "$d" | sed 's/sonar-pr-findings-//')" + jq -r --arg pr "$n" '.findings as $f + | "| #\($pr) | \($f | map(select(.advisory | not)) | length) | \($f | map(select((.advisory | not) and .severe)) | length) | \($f | map(select(.advisory)) | length) |"' "$d/findings.json" + done | sort -t'#' -k2 -n + echo "" + for d in "$RUNNER_TEMP"/findings/sonar-pr-findings-*/; do + [ -f "$d/findings.json" ] || continue + n="$(basename "$d" | sed 's/sonar-pr-findings-//')" + if [ "$(jq '[.findings[] | select(.advisory | not)] | length' "$d/findings.json")" = "0" ]; then continue; fi + echo "
PR #$n — gating findings" + echo "" + jq -r '.findings[] | select(.advisory | not) | "- `\(.rule)` \(.label) — `\(.path):\(.line // "")` \(.message | gsub("[\r\n]+"; " "))"' "$d/findings.json" + echo "" + echo "
" + done + } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 7d2e1e52..3e182f82 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -24,8 +24,27 @@ jobs: ref: main persist-credentials: false + # A merge that already set the version (a release cut in its branch, with the + # CHANGELOG section to match) is not bumped a second time: compare with the + # commit this push replaced. + - name: Skip when this push already changed the version + id: already + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BEFORE: ${{ github.event.before }} + run: | + NOW=$(jq -r '.version' package.json) + WAS=$(gh api "repos/${GITHUB_REPOSITORY}/contents/package.json?ref=${BEFORE}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null | jq -r '.version' 2>/dev/null || true) + if [ -n "$WAS" ] && [ "$WAS" != "null" ] && [ "$WAS" != "$NOW" ]; then + echo "The push itself moved the version ${WAS} → ${NOW}; no bump." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Bump patch version in package.json id: bump + if: steps.already.outputs.skip != 'true' run: | # A file with several root values ("{...}{...}") passes `jq empty` but # makes `jq -er .version` emit one line per document — validate the @@ -57,6 +76,7 @@ jobs: echo "new=$NEW_VERSION" >> "$GITHUB_OUTPUT" - name: Commit version bump + if: steps.already.outputs.skip != 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OLD_VERSION: ${{ steps.bump.outputs.old }} diff --git a/.gitignore b/.gitignore index 56299e7e..0d2bdd82 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,9 @@ test.txt # Temp commit message file .commitmsg commitmsg.txt + +# Mimosa security-plugin runtime state (hook ledgers, scan reports, session baselines) +.mimosa/ + +# Tools~/compile_check.py build directory +.compile-check/ diff --git a/AGENTS.md b/AGENTS.md index 3d6f7d62..8fe23dca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ The canonical rule set lives in `.claude/skills/`. This file is the top-level po - **FBX exporter**: code gated by `#if LIGHTMAP_UV_TOOL_FBX_EXPORTER`. - **Regex in `LightmapTransferTool.cs`**: use fully-qualified `System.Text.RegularExpressions.Regex` — no top-level `using`. - **Logging**: `UvtLog.Info` / `UvtLog.Warn` / `UvtLog.Error` (prefix `[MeshLab]`). +- **Sonar findings** (self-hosted SonarQube, `the Sonar runbook in SashaRX/Space (docs/sonar-autofix.md; here the tools live in Tools~/ and the config in ~/.config/meshlab/)`): fixed or left alone, never suppressed — no `NOSONAR`, `#pragma warning disable`, `SuppressMessage`, exclusions or issue status changes to get green. The rule policy itself (rules off, rules advisory) lives in the scanner's begin step of `.github/workflows/sonar-static-analysis.yml` and `ADVISORY_RULES` in `Tools~/sonar-pr-check.mjs`, decided per rule with a written reason. For mutation safety, package structure, serialization, CI, and release mechanics — consult the relevant skill in `.claude/skills/`, not this file. diff --git a/CHANGELOG.md b/CHANGELOG.md index c6205747..36dedd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,87 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +## [1.1.0] - 2026-10-02 + +### Changed +- **CI: the Sonar workflow removes orphan throwaway projects, and Auto Version Bump respects a release bump.** A run cancelled between its scan and its delete step left its per-PR throwaway project on the server; every run now lists the throwaway projects of this repository (`Tools~/sonar-pr-check.mjs throwaways`) and deletes those whose GitHub run is over, keeping the ones still running. The patch auto-bump on `main` skips a push that already changed `package.json`'s version (a release cut in the branch), instead of bumping it a second time. +- **Diagnostics are a library and a tab: `Editor/Diagnostics/`, Diagnostics.** The debug surfaces were scattered: the Show Debug UI setting was read in seven places, the DEBUG banner and the log-filter block were drawn inline in the UV2 Transfer tab next to the sweep and benchmark buttons, the Remesh & Bake health check (cage welding, one-sided normals, fallbacks, scale ratio, normal-map tilt) was formatted and logged in one pipeline method so nothing else could read its judgement, and `HierarchicalDiag` / `FbxMetricsExporter` sat loose at the editor root. `DebugUi` is the one gate (`Enabled`), banner and log-filter block, and `IUvToolDebugOnly` marks a tab the hub hides while the setting is off; `BakeHealth` builds the node's health as a report (summary, scale ratio, heavy-reduction flag, warnings) that the pipeline logs and a test or the Diagnostics tab can read; the two diagnostic exporters move into the folder. The new **Diagnostics** tab (Show Debug UI on) hosts the parameter sweep, the multi-model benchmark and the report rebuild (through the UV2 Transfer tab's `IBenchmarkHost`), the log filters, the hierarchical probe of the current LODGroup, both FBX metrics exports and a button to the BenchmarkReports folder; the UV2 Transfer tab's debug section keeps UV0 Analysis & Fix only, since it edits that tab's working meshes. Behaviour unchanged: same warnings and thresholds, same artefacts. Tests pin the health report's summary, the scale tolerance, the zero-normal / fallback precedence and the tilt rule on mild, heavy and proxy reductions. +- **The benchmark is a library: `Editor/Bench/`.** The parameter sweep and the multi-model benchmark lived as 800 lines of orchestration in the UV2 Transfer tab (matrix validation and cell count, the seven nested axis loops with the context snapshotted and restored, per-cell labels and recorder routing, the incremental aggregate, manifest and archive, the per-case spawn, LODGroup lookup, technique dispatch and cleanup), so no other tool could run one. `SweepRunner` (matrix → `Axes` → `Cell`s with their labels and configs, `TryValidate`, `Run` through an `ISweepHost`) and `BenchmarkRunner` (`Run` through an `IBenchmarkHost`, `FindLodGroup`, `RebuildReport`) now hold that; the tab implements the two host interfaces (context, symmetry mode, reset working copies, run pipeline, bind a LODGroup) and keeps its three buttons. `BenchmarkRecorder`, `BenchmarkSweep`, `TestSuiteAsset` and `CsvUtil` move into the same folder (GUIDs unchanged, existing suite assets stay bound). Behaviour unchanged: same cell order, labels, directories, reports and cleanup. Tests pin the axis fallbacks, the cell count and order, the label format, the oversample clamp and the validation errors. +- **One texture writer: `TextureAssets`.** A transient `Texture2D` was created, filled, encoded and destroyed by hand in the remesh exporter (PNG and EXR maps), the remesh previews (map textures, the base colour preview), the hierarchical repack diagnostics (PNG write, PNG read-back) and the UV PNG writer, and the map importer setup sat in the remesh exporter. `Editor/Assets/TextureAssets.cs` is the one implementation (`FromPixels`, `EncodePng`, `EncodeExr`, `WriteFile` with the directory created, `WritePng`, `ReadImage`, `Configure` with a `Kind` of Color / Linear / NormalMap); every site calls it, the UV PNG writer destroys its read-back texture on a failed encode too, and the repack's backdrop load no longer leaves a texture behind. Tests pin the PNG round trip through `WritePng` / `ReadImage`, the linear flag of `FromPixels`, the EXR encode and the size guards. +- **One LODGroup-from-hierarchy: `LodHierarchy`.** Rebuilding a LODGroup from `_LOD{n}` names existed twice (Cleanup over direct children with gaps kept and halving transitions, Prefab Builder recursive and compact with linear transitions), creating a group from renderers twice (Cleanup at 0.5, LOD Generation at 0.01) and from detected siblings once, compacting a LOD array lived on the tool context, and moving a root mesh into a LOD0 child, the "root renderer is LOD0" check and the root MeshCollider from a collision child were Cleanup privates with the Prefab Builder's own collider assignment next to them. `Editor/Mesh/LodHierarchy.cs` is the one implementation (`CollectByName`, `LodsFromNames` with a `Transitions` scheme, `RebuildFromNames`, `CreateFromRenderers`, `CreateFromSiblings`, `Compact`, `RootRendererIsLod0`, `MoveRootMeshToChild` + `SortChildrenAsLods`, `AddColliderFromCollisionMesh`, `AssignCollider`); each tab keeps its policy (which children, which transitions) as arguments, `UvToolContext.CompactLodArray` and the LOD Generation statics forward, and the root-mesh move carries the full renderer settings through `RendererSettings` (it copied five of them). `LodGroupUtility` stays the component-level half. Creating a group on a root that already has a LODGroup reuses that component (with Undo) instead of dereferencing the null `AddComponent` returns for a duplicate. Tests pin the name collection (direct vs recursive, collision skipped), both slot layouts and transition schemes, the rebuild on a scene group, the root-is-LOD0 check, the root-mesh move with the polycount order, the collider from a collision child and the compaction. +- **One scalar vertex channel: `VertexChannels`.** The vertex AO target channel (a colour component or a UV component) was decoded by hand in the Vertex Colors tab (read, write, the submesh merge, the preview greys, the channel name from UI tables), in the AO baker (write), in the transfer tool's export (the AO UV channel) and in the sidecar store (the AO target), and the variant export painted, snapshotted and restored `colors32` on its own. `Editor/Mesh/VertexChannels.cs` is the one implementation (`IsColor`, `ColorComponent`, `UvChannel`, `UvComponent`, `Name`, `Read`, `Write`, `WriteSubmesh`, `Levels`, `Blend`, `Greyscale`, `FillColors`, `SnapshotColors`, `RestoreColors`); `VertexAOBaker.WriteToChannel` forwards, the tab keeps its blur pipeline and UI, and the submesh-only apply reports an out-of-range submesh the same way. The submesh-only apply now leaves every vertex outside the submesh byte- or float-exact (it used to read the whole channel back clamped to [0,1] and rewrite it). Tests pin the decoding of every channel, the read/write round trip on colours and UVs with the other components kept, the submesh write, levels and blend, and the snapshot/restore of colours. +- **One UV layout topology: `UvTopology`.** The UV canvas held the channel read, the boundary-edge count, point-in-triangle, the shell vote and the shell data build as public statics on the view; the shell extractor counted boundary edges again for its descriptors (same edge key, same loop), the face→shell cache in `MeshEntry.cs` rebuilt the map inline, and the 3D viewport had its own unique-edge dedupe for wires. `Editor/Uv/UvTopology.cs` is the one implementation (`ReadUv`, `HasUv`, `BoundaryEdgePairs` over all faces or a face subset, `BoundaryLength`, `UniqueEdges`, `PointInTriangle`, `VoteBestShell`, `FaceToShell`, `BuildShellData`, `OccupiedTiles`); the canvas keeps only its per-frame caches and the GL drawing, `FaceToShellCache` and `MeshViewport3D.EdgeIndices` call in, and the shell extractor's boundary length is the same count the canvas draws. Tests pin the boundary and unique edge sets of a two-island layout, the subset cut edge, the boundary length, point-in-triangle on edges and vertices in either winding, the shell vote, the face→shell map and shell bounds. +- **One FBX writer and one sidecar store: `FbxExport`, `SidecarStore`, `RendererSettings`.** The transfer tool carried the whole FBX pipeline (about 2,400 lines: the export mesh of an entry, the UV-channel carry-over, the isolated channel re-save with its snapshots, importer scope and atomic write, the LOD-rebuild passes, the collision injection and strip, the relink after reimport, the sidecar reads and writes) as private methods on the tab, and the other tabs reached it through `FindTool()`; the sidecar asset was opened by hand in six places (load-or-create, `Set`, `SetDirty`, `SaveAssets`), the "FBX paths behind these entries" loop was written five times, the collision-hull loader lived on the Collision tab, and the renderer-settings copy existed three times (the split/merge one lost `scaleInLightmap` and the lightmap scale/offset). `Editor/Assets/FbxExport.cs` now holds the mechanics (`BuildExportMesh`, `WriteChannels`, `Write`, `WriteAtomic`, `PromoteRootMeshToLod0Child`, `ReplaceMeshes`, `AddLodChild`, `PruneStaleChildren`, `NormalizeExportHierarchy`, `InjectCollisionMeshes`, `StripCollisionMeshes`, `TrimMaterialArrays`, `RelinkSceneMeshReferences`, `RemoveDefaultMaterialRemaps`, the preflight and the UV channel helpers), `Editor/Assets/SidecarStore.cs` the sidecar (`Load`/`LoadOrCreate`/`Save`, `FbxPaths`, `TryFindFirst`, `SaveEntries`, `StoreForImport`, `ArmTransientReplay`, `ClearUv2Entries`, `Delete`, `TryBuildEntry`, `CollisionMeshes` with its validation, `LoadSettings`/`SaveSettings`), and `Editor/Mesh/RendererSettings.cs` the one renderer copy. The transfer tool keeps the policy only — dialogs, backups, the importer lock, which entries, what to refresh — and its wide export reads as the sequence of those passes; the hub, the Collision tab, vertex colour baking, LOD generation, split/merge and the remesh exporter call the libraries directly (`CollisionMeshTool.GetCollisionMeshesFromSidecar` forwards). One behaviour change: the wide LOD-rebuild export now rejects an empty exporter output as a failure, as the isolated path and the remesh exporter always did, instead of reporting success and restoring the `.meta` over it. Tests cover the naming rules, the UV carry-over, the AO target mapping, the hierarchy passes on a scratch hierarchy and the collision entry validation. +- **One way to read a mesh, one way to bake a matrix, one box math: `MeshAccess`, `MeshTransform`, `MeshGeometry`.** Reading a Read/Write-disabled mesh was done five ways: the UV canvas carried the only correct one (every attribute through `AcquireReadOnlyMeshData`, an importer round-trip only when the engine refuses even that), vertex AO instantiated the mesh, the Cleanup tab flipped the FBX importer's Read/Write on and off around its reads (two reimports per fix, with the postprocessor bypassed by hand), the rest just skipped unreadable meshes. `Editor/Mesh/MeshAccess.cs` now owns the canvas's implementation (`ReadableCopy`, `Readable`); the transfer tool, the remesh capture, vertex AO and both Cleanup fixes read through it, and no tool touches an importer to read a mesh any more. Baking a transform into a mesh existed three times (`LightmapTransferTool`, `PrefabBuilderTool`, `RemeshExporter`) and two of them transformed normals with the matrix itself, which is wrong under non-uniform scale and never flipped the winding of a mirrored mesh; `MeshTransform.BakeMatrix` does it once, correctly (inverse-transpose normals, re-orthogonalised tangents, winding and handedness flipped under a negative determinant), and `WorldBounds` / `CombinedBounds` replace the per-tool unions. The pure box math (`TransformBounds`, `BoundsDistance`) joins `MeshGeometry`, where the transfer, vertex-colour-baking and AO copies forward to it. The readable copy keeps every submesh at its own topology (quads and lines were triangulated), keeps an all-zero UV channel, reads colours as floats (a `Color32` read quantised float colour streams), and warns once when a skinned Read/Write-disabled mesh goes through `MeshData`, which has no bone-weight accessor; a mirroring `BakeMatrix` re-winds quads as quads. +- **One split-by-material / merge-same-material: `MeshSplitMerge`.** The Cleanup and Prefab Builder tabs each carried their own scan, split and merge (about 450 and 260 lines) and they had drifted: the Prefab Builder merge kept only positions, normals and UV0 (tangents, colours and UV2 were lost), its split read the materials while its own preview had them swapped, and the Cleanup split dropped the static flags. `Editor/Mesh/MeshSplitMerge.cs` is the one implementation: `Scan` (candidates and groups), `SplitByMaterial` (one child per submesh with every attribute, the renderer settings and static flags, slotted into the LODGroup where the source was), `MergeSameMaterial` (into the first renderer, which keeps its components and references and is renamed `{base}_LOD{n}`, every attribute carried over, the others leave the LODGroup), `ExtractSubmesh` (all eight UV channels at their stored width, bone weights included; `MeshHygieneUtility.ExtractSubmesh` forwards to it). Both tabs keep only their buttons and lists; previews are restored before materials are read; Undo and prefab-instance modifications are recorded in one place. A merge whose first renderer is not readable is skipped instead of replacing that renderer's geometry with the others' alone; a split whose submeshes are all empty leaves the object alone; a split source that carries children or other components stays as their container and only loses its mesh pair; a mirrored part (negative-determinant transform) is re-wound and its tangent handedness flipped when merged, and part normals go through the inverse transpose. +- **One reader of the naming rules: `MeshNaming`.** LOD and collision suffixes were parsed by nine different regexes — `UvToolContext.ExtractGroupKey`, three in `MeshHygieneUtility`, two in `LodPipelineOps`, three inline in `LodGenerationTool`, five inline in `CleanupTool`, three in `PrefabBuilderTool`, a hand-rolled `_COL`/`_COL_Hull{N}` pair in `VertexColorBakingTool`, a `LastIndexOf("_LOD")` scan in `RemeshSource` and two more in `LightmapTransferTool` — and they had drifted: some required `_LOD`, some accepted `-LOD` and ` LOD`, one accepted `FooLOD1` with no separator, the collision rules disagreed on `_COL_*` and `_Collider`. `Editor/Mesh/MeshNaming.cs` is now the one implementation (`TryParseLod` with the LODGroup index limit, `LodIndex`, `HasLodSuffix`, `StripLod`, `SplitLodSuffix` keeping the suffix text verbatim for renames, `LodName`, `IsCollision`, `StripCollision`, `HasLodOrCollisionSuffix`, `GroupKey`, `StripPipelineSuffixes`); every site calls it, `ExtractGroupKey` and the `MeshHygieneUtility` helpers forward to it, and a test file pins the accepted forms. The LOD separator is `_`, `-` or whitespace everywhere now (a bare `FooLOD1` is no longer a LOD name anywhere), and the collision rule is the hygiene one everywhere (`_COL`, `_COL_Hull{N}`, `_COL_*`, `_Collider`, `_Collision`; `_COLOR` is not). +- **One geometry module for every projecting and baking tool.** An audit of the BVH and GPU paths found no duplicate BVH (one 3D `TriangleBvh`, one UV-space `TriangleBvh2D`, the GPU AO kernel consuming the 3D one's layout) but a ring of tool-local copies around them: face normals computed in five places, bit-exact position welding in four, the golden-spiral sample directions in two, 2D barycentrics in two, a point–box distance and a closest-point-on-triangle duplicated in `HierarchicalRepack` for a brute-force O(N)-per-query nearest-face scan, and the "blit to a temporary render texture, ReadPixels, clean up" GPU readback written out three times (source maps, lightmap regions, probe cubemaps). They now live in `Editor/Geometry/`: `MeshGeometry` (face normals, `WeldPositions`, `SphereDirections`, `Barycentric`, `SqDistToAabb`) and `GpuReadback` (`Read` / `ReadColors`), used by the remesh bake, trim and cage, the Beauty capture, the source capture, vertex AO (CPU and GPU), the edge analyzer, the UV canvas and the hierarchical repack, whose nearest-face projection runs on the BVH now (O(log N)). Two dead shaders from the abandoned depth-based AO path (`VertexAOAccum.compute`, `VertexAODepth.shader`) are removed; nothing referenced them. +- **Remesh & Bake: the result's atlas lives in the canvas, the right panel is 3D + Maps.** The right sidebar's own UV view (a square with tinted islands over the base color) is gone; the canvas's UV mode now shows the Remesh & Bake result once Normals & UV ran, with the same shells, wire, border, spot picking, status line and backgrounds as any mesh (the baked base color under it, or the checker), and an **Islands** fill mode tints every UV shell. The right panel is a vertical list fit for the narrow third column — Stage, Baked base color, Baked normal map, Trim mask, Cage shells, the atlas's island count and texel usage — and the Maps view; its own Wire, Shaded and Vertex color toggles are gone because the canvas owns them: the status bar's Wire now draws on tool content too, the 3D shading row's modes apply to the stage mesh, and the result's UV layer (fill mode, island borders, spot picking) is drawn on the model in 3D as for any context mesh (the hub pairs a tool's 3D items with its UV entries by mesh). Tools opt into this through `IUvToolUvContent` (the hub sets `UvCanvasView.EntriesOverride` each frame); a `MeshEntry.previewTexture` carries a tool-made entry's background in the UV canvas and is skipped by the 3D layer, which would only dim the surface already showing it. +- **Remesh & Bake never touches the source importers.** The remesh stage used to flip `isReadable` on for every imported model under the source root and back off afterwards — two reimports of every FBX per run, which in projects with heavy model postprocessors (Bakery's UV-overlap check) cost more than the capture itself. Read/Write-disabled meshes are read through `Mesh.AcquireReadOnlyMeshData` regardless, so the flip is gone; skinned sources bake through `BakeMesh` as before. +- **Remesh & Bake: the tool is split into a stage pipeline, an exporter and the tab.** `RemeshBakeTool` (1000+ lines mixing IMGUI, two copies of every stage for the weld and keep-hierarchy lanes, and two near-identical save paths) is now the tab only. `RemeshPipeline` runs the stages over a list of nodes — the weld is one node in root space, keep-hierarchy one node per renderer — with a single loop per stage, one `ClearFrom`, and previews drawn from the primary (largest) node; `RemeshExporter` writes maps, materials, meshes and the prefab/FBX through one path for both lanes; `RemeshSource.CollectRenderers` is the one renderer filter (the weld capture and the node list agree by construction). `RemeshBeauty` keeps its scene data in world space and converts per capture space (`ForSpace`), so one snapshot serves every node. No settings, ABI or asset formats changed. + +### Fixed +- **Beauty bake: scene lighting matches the game in three more cases.** Under Subtractive mixed lighting a lightmapped receiver no longer adds the mixed lights' realtime direct term on top of the lightmap that already carries it; a shadow caster on a layer a light's culling mask excludes no longer shadows that light (the ray steps past it, for the source's own faces and the scene casters alike); and a scene caster without UV0 (procedural or imported geometry) stays in the shadow BVH instead of being dropped by the material-transfer UV check. +- **Beauty results in URP save with `Universal Render Pipeline/Unlit`** (`_BaseMap`) instead of the Built-in `Unlit/Texture`, which renders pink there. +- **3D canvas: the shading row offers Normals and Tangents only where a mesh carries them**, as it already did for UV channels, and a mesh whose colours or UVs a tool writes (Vertex Colors, AO) re-encodes on the next frame instead of showing the pre-edit clone. +- **Variant export restores a mesh that had no vertex colours to having none** rather than leaving a full-length black stream behind. +- **`UvCanvasView` keeps `VoteBestShell`, `BuildBoundaryEdgePairs`, `PointInTriangle` and `MakeReadableCopy` as obsolete public forwarders** to `UvTopology` / `MeshAccess`, so code compiled against them keeps building. +- **BVH rays are watertight and the tree is SAH-built.** Two findings from a read of the open implementations (tinybvh, the Woop–Benthin–Wald JCGT 2013 paper). The ray–triangle test was Möller–Trumbore, which can leak a ray between two adjacent triangles exactly along their shared edge or through a shared vertex — on a bake that is a missed texel for every seam a ray happens to graze. The test is now the watertight one: vertices translated to the ray origin and sheared so the ray becomes (0,0,1), three 2D edge functions with an exact-zero edge re-evaluated in double precision and counted as inside, two-sided with the determinant's sign folded into the depth test; the same test in `BvhTraversal.hlsl` (conservative without the double fallback on GPUs). The build was a midpoint split of the longest axis with 4-triangle leaves; it is now a binned SAH build (8 bins on each axis over the centroid bounds, cheapest plane by surface-area heuristic, a split only when it beats testing the node, leaves capped at 8 with a midpoint fallback for coincident centroids), which visits fewer nodes per ray and per nearest query on the same geometry — the GPU kernels traverse the same tree. Tests cast rays through every shared vertex and edge of a grid and compare the tree's answers with an exhaustive scan over a random soup. +- **BVH rays hit millimetre-scale meshes.** The ray–triangle test rejected a triangle as "parallel" when its Möller–Trumbore determinant was under an absolute 1e-7, but that determinant scales with the product of two edge lengths, so every face of a small model (edges around 1e-4 units) failed the test and the bake, the trim's reach and vertex AO saw nothing. The threshold is relative to the triangle's own scale now, on the CPU BVH and in the GPU AO kernel alike; a regression test casts at a 1e-4 triangle. +- **Remesh & Bake: Trim to source surface now actually removes the back of an open sheet.** The trim judged a remesh face by position ("is its centroid behind the source sheet?"), but the voxel remesher fits its output vertices onto the input surface, so the slab around an open wall is not a cell thick: its front and back faces lie ON the sheet, both at zero distance, and both passed — a zero-thickness double-sided result on every wall of a non-closed source (half the positions of a bathroom interior carried two sides), which then broke the projection cage and baked texels from the wrong side. The trim now keeps a face only when a source face within two cells points the same way: the back (opposite normal) and the rims (perpendicular) go, a closed or genuinely double-sided source keeps both sides, an inside-out source is judged by its flipped normals, and when neither reading keeps a tenth of the remesh the stage warns instead of silently keeping everything. On a one-sided open room the trim removes 50% of the remesh (back and rims) where it removed 3% before. +- **Remesh & Bake, keep hierarchy: nothing is captured twice, sheared transforms survive, and previews/exports are coherent.** A node's capture folded its whole subtree in while every child renderer was also its own node, so nested renderers baked into two meshes; each renderer is now exactly one node. A node's capture space is the TRS the save restores (root × TRS), so a rotated child under a non-uniformly scaled parent folds the residual shear into its geometry instead of losing it. The node list applies the same mesh-asset name filters (`_COL`, `_LOD{n}`) as the weld, so a normally named object carrying a collision mesh no longer aborts the whole run. Re-running Normals & UV destroys the previous node meshes (each iteration leaked one `HideAndDontSave` mesh per node); a re-bake with vertex color transfer off clears the colors the previous bake left on every node; the UV and cage previews show the largest node's geometry instead of "run the UV stage"; and the hierarchy save imports the map files before configuring their importers (one `AssetEditingScope` deferred the imports past the importer lookup, which could fail and roll the whole folder back). +- **Remesh & Bake: Beauty bakes shade lightmapped faces as the game does.** The lightmap was written out as the colour itself; Unity lightmaps store irradiance that the Lit shader multiplies by albedo, so textured lightmapped objects baked as flat light. Lightmapped faces are now albedo × (lightmap + realtime direct) + emission. Point and spot lights respect their shadow setting and strength (they cast opaque shadows regardless before), light ranges and shadow distances are evaluated in world units (a root scaled to 0.01 or 100 no longer stretches or shrinks their reach), and keep-hierarchy nodes evaluate the lighting in their own space instead of the root's. +- **Remesh & Bake: *Smooth* and *Angle* hard-edge modes are smooth across UV islands again.** The post-unwrap normal regeneration accumulated per output vertex, and xatlas splits vertices along every chart border, so every mode hardened island borders — *Smooth* behaved like *UV islands*, *Angle* like *UV islands + angle*. Copies along chart borders are now grouped back by position and native crease group, and normal smoothing runs over the same groups. Tangents are re-orthogonalized against the final normals so the saved frame matches the bake's. The front-face-filtered nearest fallback is bounded by the projection distance like the unfiltered one (it could sample an arbitrarily distant part across a gap). +- **Remesh & Bake, normalized saves: bounds and tangents.** Baking the source scale into the mesh left `mesh.bounds` at the unscaled size (culling at the wrong extent) and scaled tangents by the inverse like normals — tangents are direction vectors and take the forward scale, then re-orthogonalize; a mirroring scale also flips the tangent handedness with the winding. Skinned-only roots now follow the hierarchy selection like mesh roots. The saved model's scale is the one captured at remesh time, not whatever is selected at save time. +- **Remesh & Bake no longer reads whole scene lightmaps at capture.** The capture read every lightmap a renderer referenced in full, as float pixels (16 bytes per texel, 1 GiB for an 8K map, plus the same again as a RenderTexture) — in every bake mode — which failed with "GetPixels: array size too large" on large Bakery lightmaps and drove the GPU out of memory (D3D `8007000e`, crashing the asset import worker on the following reimport). The capture now keeps only the texture references; a Beauty bake reads, right before it runs, just the region each renderer occupies (its scale/offset rect plus one texel of padding, downsampled above 2048² texels) and releases the pixels when the bake ends. Materials bakes never touch the lightmaps. +- **FBX isolated export restores the source importer on every exit.** The variant `keepQuads` toggle and the re-save `isReadable` flip were put back only in Phase 5 (and the toggle also in the catch), so the early "no matching meshes" return and a failure after Phase 3 left the source importer changed. Both are now restored by a scope disposed at method exit. +- **Sweep gallery pages are reachable and never overwrite each other.** The index and per-page navigation linked `_gallery_.html` while the page was written under the sanitized name (a model named `A B` had no working link), and two models sanitizing to the same string wrote the same file. Every link now uses the written name, and colliding names get a stable `_2`, `_3`… suffix in model order. +- **Repository: a stray screenshot committed at the package root is removed.** + +### Added +- **CI: the whole package compiles without a Unity licence, and a SonarQube scan.** `Tools~/compile_check.py` builds `Editor/` and `Tests/` with the .NET SDK against the UnityEngine/UnityEditor reference assemblies from NuGet (Unity3D.SDK 2021.1.14.1), once without and once with `LIGHTMAP_UV_TOOL_FBX_EXPORTER` (a stub stands in for the FBX Exporter), and fails on any CS error with the repo-relative file and line; the four APIs the 2021 references predate are bridged on copied sources, never the repo. The *Unity Package Checks* workflow runs it on every push and PR next to the CS0103 heuristic. A *SonarQube* workflow set, modelled on SashaRX/Space, analyses the same build with `dotnet-sonarscanner` on the self-hosted server (`SONAR_TOKEN` secret; host and project key as repository variables) and skips itself with a notice while the token is missing: a push to `main` updates the project itself with the quality gate enforced; every pull request is scanned into a per-run throwaway project and `Tools~/sonar-pr-check.mjs` keeps the open issues on the lines the PR changes (annotations, job summary, a findings artifact, the project deleted afterwards; any new finding reds the check), so the Community edition's one-branch-per-project limit still yields a per-PR verdict; each check also uploads a findings bundle (`findings.json`, a fixer prompt) as an artifact; `sonar-backlog-report.yml` is the read-only triage snapshot and `Tools~/sonar-mcp.sh` the read-only SonarQube MCP server for interactive sessions. Four rules are switched off in configuration with their reasons (S125 prose comments, S1104 public data fields, S107 scalar math parameters, S1168 null as "absent") and three are advisory, reported but not gating (S3776 cognitive complexity, S3267 LINQ-over-loop, S3358 nested ternary, S1075 hardcoded path delimiter on Unity asset paths). A PR that changes the Sonar tooling checks every open PR with it; `workflow_dispatch` takes `open`, PR numbers or `main`. Runbook: `the Sonar runbook in SashaRX/Space (docs/sonar-autofix.md; here the tools live in Tools~/ and the config in ~/.config/meshlab/)`. +- **The GPU BVH does everything the CPU one does, and the bake uses it.** The only GPU tree so far was the vertex-AO kernel's private copy, which answered just "how far until a hit". `Shaders/BvhTraversal.hlsl` is now the one traversal for every compute kernel — the same node layout as `TriangleBvh`, the same relative ray–triangle test, closest-point-on-triangle with barycentrics, ordered (nearer child first) ray traversal, nearest with box-distance pruning, the facing filter, the normal filter and the either-side mask — and `Shaders/BvhQueries.compute` answers batches of ray and nearest-point queries with the same hit records the CPU returns (face index, t or distance², weights of vertices 0, 1, 2). `GpuBvh` (C#) uploads a `TriangleBvh` once and streams batches through it, or binds the tree to another kernel; the AO kernel now includes the shared traversal and binds the tree through it instead of carrying its own. The Remesh & Bake bake is restructured into one code path with a pluggable resolver: Prepare (coverage, BVH, cage, orientation probe), then bands of rows — sample requests built on workers, answered either by the CPU BVH in parallel or by the GPU in one dispatch per batch, evaluated on workers — then the fills, diagnostics, dilation and vertex colours. **GPU projection** (bake stage, default on where compute shaders exist) picks the GPU resolver; the pipeline drives the band loop from the main thread so the dispatches are legal, falls back to the CPU when the kernel asset is missing, and the bake status says which path ran. Results are the CPU's. +- **Remesh & Bake: two-sided sources, a visible trim mask, and a re-wound sheet.** A material that renders both sides (Cull Off, double-sided) shows its sheet from behind too. **Source backfaces** (remesh stage) decides which source faces count from behind: *From materials* (default) reads each captured material's cull mode property (`_Cull`, `_CullMode` = Off) or double-sided switch (`_DoubleSidedEnable`, `_TwoSided`, `_DoubleSided`), *Always* treats every face as two-sided (for a `Cull Off` baked into the shader, which no property reveals), *Never* keeps only fronts. A two-sided source still trims to ONE sheet — doubling the geometry would double the atlas and the triangle count for nothing — and the saved material renders both sides of it instead (URP Lit/Unlit: cull Off and double-sided GI; Standard and Unlit/Texture have no two-sided mode, so the save warns and says so in its status); two-sided faces pass the bake's front-face filter from either side (ray and nearest fallback alike) and cast no vote in the winding probe. After the cut the kept sheet is re-wound to one consistent orientation per connected piece, the majority of each piece keeping the side that agreed with the source, so a source modeled with arbitrary winding comes out orientable. The Remesh stage's new **Trim** toggle (3D panel) colours the untrimmed remesh by class — green kept, red back of a sheet, orange rim / no source within reach — so what the trim removes, and why, is visible before the simplifier runs; the remesh status now also reaches the Console, with the trimmed, re-wound and two-sided counts, and RemeshDiag prints the per-class breakdown. +- **Remesh & Bake: a sided, fitted projection cage.** The cage was one direction per welded position: the area-weighted face normals of every vertex copy at that position, summed and smoothed. On a double-sided sheet — a wall of a non-closed source that the voxel remesh turned into a one-cell slab and the simplifier collapsed to zero thickness, both windings on the same vertices — the two sides summed to nothing and the normalized noise sent rays off at up to 180° from their face, with the cage preview spiking across the whole model and most texels baking from the wrong side. The cage is now built per face corner and per **side**: corners at one position are clustered by the hemisphere their face normals share (within 120°), each cluster is averaged (face area × corner angle) and Laplacian-smoothed over its own connectivity, so UV and crease splits still weld into one smooth direction while a double-sided sheet keeps a front and a back; every corner direction is checked against its own face and falls back to the unsmoothed side, then the face normal. **Cage smoothing** (bake stage, 0–10, default 2) exposes the passes. **Fit cage to source** (default on) measures where the source sits along each side's ray (both ways, then nearest point), doubles it for oblique surfaces, clamps it between 1× and 8× the projection distance and smooths it over the sides, so the rays reach as far as the decimation drifted and no further; the nearest fallback is bounded by the same reach. The Cage preview pushes every corner by its own reach, one line per welded side pair (a double-sided sheet shows both shells), rebuilt live from distance, smoothing, fit and source. RemeshDiag reports the side and double-sided position counts and the longest reach; the bake staleness key covers the new settings. +- **A shared 3D view in the canvas, for every tab.** The canvas gains a **UV | 3D** switch: a segmented pill at the bottom centre with the active side in the accent colour, present in every canvas state (also when the UV canvas has nothing to draw), remembered across sessions, and flipped with **Tab** while the mouse is over the canvas. The 3D side looks like a scene view: a blue-grey background, a fading ground grid under the content (Grid toggle), the pivot's axes, and the shading modes as a segmented row along the top of the canvas. The 3D side is one module, `MeshViewport3D`: an orbitable lit view (drag orbits, middle button or alt-drag pans, scroll zooms, F or a middle double-click frames) with shading modes *Shaded* (scene materials), *Vert Colors*, *Normals*, *Tangents* and *UV0–UV3* (channels offered only where a mesh carries them), a wire overlay and a Lit toggle. By default it shows the preview LOD's meshes exactly as the UV canvas lists them (isolated mesh group included). Tools take part through `IUvTool3D`: they can replace the content and draw overlays (meshes, wire, coloured lines, screen-sized dots). The 3D side is the UV viewer on the model: the same bottom bar (fill mode and alpha, Wire, Bdr, preview mode with checker channels and lightmap exposure) and the same Spot / Lock / Clear and UV0 / UV1 controls drive it — the active fill mode, borders and preview background are rendered per mesh into a UV-space layer and laid over the surface through the preview UV channel, Wire draws as true 3D lines, and Spot picks the face under the mouse (camera ray) into the same hover/selection state the UV canvas and the tools read, with the hovered and selected shells highlighted on the surface and the info panel in the corner. Remesh & Bake now shows its pipeline stage in that view — its right sidebar keeps the stage picker and the Wire / Shaded / Texture / Bump / Vertex color / Cage toggles, the small embedded preview is gone — and Prefab Builder's *Edges* and *Problems* scene previews paint their edge classes and unused-vertex dots there too. +- **Remesh & Bake: proxy shapes rebuilt — oriented boxes and hulls, a part filter before the remesh, vertex-color albedo tint.** The *Box set* shape (recursive gap splitting of a point cloud, which guessed wrong on anything that fills its bounds) is gone. **Shape** now offers *Bounding box* — one oriented box per captured renderer, measured over the renderer's geometry along its own authored axes and placed back in the capture space (a yawed building keeps a yawed box, not the inflated axis-aligned one; weld lane: one mesh of per-renderer boxes; keep-hierarchy: one box per node) — and *Hull* — the capture run through a coarse solid voxel pass (*Hull resolution*) and a strongly regularized simplification to *Hull triangles*, a closed rounded blob that follows L- and T-shapes. **Exclude parts smaller than** (a fraction of the capture diagonal) and **Exclude rods thinner than** (in voxel cells) run before any shape over the connected components of the position-welded triangle graph: a piece goes when its extent is under the size floor or when its cross-section — the two smaller of its extents along its own principal axes — is under the rod threshold, the section the voxel grid cannot carry anyway. Thin alone is not the criterion: a gate leaf or a glass pane has one thin extent and stays, a pipe or railing has two and goes, so rods stop inflating boxes and hulls; the status reports the counts and a filter that would remove everything is skipped with a warning. Proxy shapes bake through **proxy projection**: each texel casts along its face normal from just outside the proxy as deep as *Proxy search depth* allows (a fraction of the capture diagonal, 0.1 by default), takes the first source hit, else the nearest surface within the same reach — no cage — and texels that find no geometry within reach are written with alpha 0 (filled from the nearest hit so the color map stays clean), counted in the status, so an alpha-clipping shader gets a silhouette-correct impostor. **Vertex color tints albedo** (bake stage, on by default, inert on meshes without colors) multiplies the baked albedo by the source's interpolated vertex color RGB as linear, the way vertex-tinting shaders read it. Materials on shaders other than Standard / URP Lit are reported in one summary line per capture, grouped by shader, instead of one warning per material. The RemeshDiag "map is dominated by extreme normals" warning is skipped for proxy shapes and for reductions stronger than 5:1, where the geometry is meant to move into the normal map. **Highlight capture in Scene** (remesh stage) paints the source in the Scene view as the capture will see it, live with the filter sliders: green captured, orange small parts, red rods, grey excluded renderers (LOD1+, collision, disabled), with per-class triangle counts in the sidebar — a geometry-only capture, no material reads. The remesh and bake staleness keys cover the new settings. +- **Remesh & Bake: Keep hierarchy.** A *Keep hierarchy* toggle (remesh stage, default off) remeshes every captured node SEPARATELY instead of welding the subtree into one mesh: each renderer that the weld would have folded in (active, non-collision, LOD0 — the same filters, with its own descendants merged into its node) runs the full voxel → simplify → unwrap → bake chain in its own local space, and the save produces a hierarchy — one root prefab named after the source, one child per node at its captured root-relative transform, each with its own baked material and mesh asset (`_LOD0.asset` + `.mat` + the five map files). The FBX lane stays a single-mesh feature (a multi-node FBX round-trip re-imports per-node normals/mappings for no gain over the native prefab), so the FBX option applies to the weld only; the source root's scale stays on the prefab root, and *Normalized size* (a single-vertex-space concept) logs that it is weld-only. Changing the toggle invalidates the remesh stage through the staleness key like any other capture-scope setting. + +### Fixed +- **One hostile mesh no longer kills the whole capture.** A non-triangle submesh (`WindowsCurtain_15`'s line geometry) or a mesh without UV0 threw and aborted the entire scene-block run. Non-triangle submeshes now drop out individually (the renderer survives on its triangle submeshes; a renderer with none left is skipped), UV0-less renderers skip with a warning, and every renderer is isolated so an unreadable import costs its own exclusion — never the capture. +- **Read/Write-disabled imports the MeshData path refuses are read through their importer.** Unity 6000.2's `AcquireReadOnlyMeshData` still throws `isReadable is false` for some imports. When it does, the read falls back to flipping THAT file's ModelImporter to Read/Write, reading, and restoring it — a reimport pair only for files the MeshData path cannot serve (typically none), never the flip-everything cost that was reverted before. +- **Collision meshes with suffixed names are excluded.** The collision matcher only accepted `Name_COL` and `Name_COL_Hull{N}` literally, so assets carrying variants like `Name_COL_M` (no UV0, rightly) aborted the whole capture with "needs source UV0". The matcher now takes the `_COL` token with any trailing suffix token (`_COL_M`, `_COL_S`, `_COL_Hull2`…) while ordinary words (`_COLOR`, `_COLLECTION`) stay out, and the capture checks the mesh asset's name for the suffix as well, not only the GameObject's. +- **The projection cage is a proper smooth cage.** The welded cage directions were raw area-weighted sums, so the sliver noise of the adaptive decimation went straight into them — the preview shells inflated lumpy and every tight concavity folded through to the far side. The directions are now Laplacian-smoothed over the welded connectivity (smoothing runs on the welded mesh so it flows through chart borders and hard edges instead of re-splitting them — the "fully smooth cage" bakers prescribe, applied to the bake rays too), and each preview shell's offset stops short of self-intersection by casting its segment against the surface, showing a pinch in wheel wells instead of noise. +- **Beauty bakes decode Bakery lightmaps correctly.** Unity's own RGBA32 lightmaps are RGBM (`rgb × a × 8`); Bakery's 8-bit output is plain linear with alpha pinned at 1, which the unconditional RGBM decode would overbrighten eightfold. HDR encodings (Bakery's default `.hdr`) always decoded directly; 8-bit maps are now auto-detected by the pinned alpha. Direction textures are not sampled (the colour map already carries each surface's irradiance) and shadowmask lights contribute through their realtime component with the bake's ray shadows standing in for the mask. +- **Remesh & Bake: the voxel remesh is trimmed to the source surface.** The voxelizer closes every surface, so an open source (a wall sheet, a roof plane, a curtain) came back as a thin slab — a front, a back and rims — and the back side baked and shipped as geometry. *Trim to source surface* (voxel remesh stage, default on) now masks the remesh after voxelization: a face stays only when a source face lies within two voxel cells of it, parallel to it (either winding), with the remesh face on that source face's front side; the slab's back and rims have none and go, closed sources are left whole, double-sided sources keep both sides, and a source wound inside out is judged by its flipped normals. The status reports the trimmed face count; the toggle is part of the remesh staleness key. +- **Remesh & Bake: proxy bakes are bounded and much faster; the bake reports its time.** A proxy texel looked through the whole proxy depth (the capture diagonal) with no fallback, so a 56k-triangle block under a hull cost a full BVH traversal for every texel that saw nothing and showed the far side of a courtyard through the block. The look is now bounded by *Proxy search depth* with a nearest-surface fallback within the same reach, the BVH visits the nearer child first so a hit culls the rest of a long ray, Beauty's scene shadow casters are limited to the source's surroundings (its bounds grown by twice their size), and the bake status ends with the elapsed seconds. +- **Remesh & Bake, Beauty: scene shadows, light culling masks, probe blending, skybox reflections.** Shadow rays saw only the source's own geometry, so a neighbouring building or a sibling keep-hierarchy node never shadowed it; the lighting snapshot now also captures the scene's shadow casters (geometry-only, nearest first, 4M-triangle budget) and tests them in world space. Lights whose culling mask excludes the captured renderer's layer no longer light or shadow it. Reflection probes are blended as the runtime blends them (weight 1 inside the box, 0 across the blend distance, two probes by importance, the environment taking the rest) instead of one hard-edged pick, and with Environment Reflections set to Skybox the generated skybox reflection is the fallback instead of black. +- **Remesh & Bake: a stage reports done only when every keep-hierarchy node has its output.** The stage check looked at the first node, so a repaint between two nodes of a Simplify, Unwrap or Bake dereferenced a node that had not run yet (an `OnGUI` null reference). A `Dispose()` during a run (tab switch, window close) cleared the node list under the still-running stage; the clear now waits for the run to observe its cancellation. A renderer that fails halfway through its capture (a missing material on a later submesh) is rolled back instead of leaving orphan vertices for the oriented boxes. The save resolves its output folder from the root the remesh stage captured, not from whatever is selected at save time. Unknown shaders keep each map's own tiling and offset instead of the base map's. The benchmark sweep drains git's asynchronous output before reading it. +- **Remesh & Bake: the front-face filter's orientation probe no longer depends on the target's density.** The probe compared source face normals with the target's cage normals at the nearest point, which splits its vote once the target is a coarse proxy (a 112-triangle garage block, a box, a hull) and switched the filter off exactly where thin walls matter most. It now judges from outside: 256 rays from a sphere around the source toward its centre meet an outer surface first, and that triangle's winding against the ray decides; the 70% majority rule and the off state for open sheets are unchanged. +- **Remesh & Bake: projection rays no longer sample through thin walls.** The bake raycast returned the closest intersection with no facing test, and the ray travels twice the projection distance through the target — on thin geometry (armor plates, fenders, any two-sided shell) it pierced the wall and baked the far side's texture as periodic mirrored/garbled patches. A front-face filter now only accepts source triangles facing the ray, on the ray path and on the nearest-fallback alike. Its orientation comes from a consensus probe of the source winding against the target's cage normals (inverted-winding imports are flipped automatically); without a clear ≥70% majority the filter stays off and the bake behaves exactly as before. The RemeshDiag projection line reports whether the filter engaged. +- **Remesh & Bake: Read/Write-disabled sources capture again.** `MakeReadableCopy` read the classic vertex getters, which on a non-readable import log "Not allowed to access" and return EMPTY arrays — the copy came out triangle-less and the capture aborted with "No source triangles found". Non-readable meshes now clone through `Mesh.AcquireReadOnlyMeshData` (vertices, normals, tangents, colors, UV channels, submeshes; bone weights have no MeshData accessor and stay a readable-only path). The readable fast path is unchanged. +- **Skinned captures bake the pose the scene shows.** `BakeMesh` uses the last evaluated skinning, which in edit mode can predate the current bone transforms — every skinned part then baked at its authored origin instead of its posed place. The bone list is reassigned before baking to mark the skinning dirty and force a re-evaluation. +- **Remesh & Bake: split-normal regeneration is scale-free and self-heals against the native normals.** The post-UV rebuild gated each vertex's accumulation behind an absolute floor (`sqrMagnitude > 1e-30`), which misclassifies valid smooth vertices on real ~5 mm captures (raw face crosses sit near 1e-7) and leaves their normals zero — every mode then baked through zeroed ray directions (tilt saturation, near-total projection fallbacks). The gate is now relative to the strongest accumulation on the mesh, and any vertex that still degenerates restores the parsed native meshopt normal (same crease splits, smooth across chart borders — a soft edge beats a dead one) with a warning naming the count. The native test suite gains a dense 5 mm sphere through the island-mode path (crease = π) pinning unit normals, normalized UVs and unit tangents at capture scale. +- **UvtLog no longer reads EditorPrefs from worker threads.** The level/category caches fill lazily, so the first remesh log emitted from a `Task.Run` worker could hit `EditorPrefs.GetInt` off the main thread and throw `UnityException`; both caches are now warmed in `[InitializeOnLoadMethod]` and workers only ever read the cache. +- **Remesh & Bake: the unwrap wire layout really is sixteen float32 per vertex.** The ABI-3 native struct carried only twelve floats (no reserved tail), so the flat buffer the editor and the native tests read at a 16-float stride was packed at 12 — every field after the first vertex came out shifted garbage (the native test failed on `unit normals` / `normalized UV` on CI, which stopped the plugin rebuild and left the editor on stale ABI-2 binaries). A `static_assert` now pins the struct to the documented stride, and the version-mismatch error states the expected and loaded ABI plus the update/restart steps instead of a bare "Unsupported remesh native ABI". + +### Changed +- **Beauty reflections and light response follow the game's specular and bump paths.** Probe reflections previously used one sharp readback lerped toward a flat average with an ad-hoc Fresnel. They now walk the game's specular path: URP's `BoxProjectedCubemapDirection` for box-probe localization, a six-level prefiltered equirectangular mip chain read back per probe (through a `texCUBElod` blit) selected by the pipeline's `r(1.7−0.7r)·maxMip` remap with the fractional part lerped between levels, and `EnvironmentBRDFSpecular` verbatim — surface reduction `1/(r²+1)`, grazing term `saturate(smoothness+reflectivity)`, Schlick Fresnel on NdotV. All Beauty lighting — the directional-lightmap half-Lambert, realtime N·L, ambient and the specular view dot — now evaluates against the source's normal-map-perturbed normal, exactly as the game shades. +- **Beauty bakes reproduce the game's exact directional-lightmap response.** The lightmap colour was sampled flat, which overlights every surface whose normal disagrees with the baked dominant direction — the game shades those surfaces darker. The bake now ports URP's `SampleDirectionalLightmap` verbatim: the direction texture's encoded dominant direction (captured per face alongside the colour map, linear readback) is dotted with the surface's world normal as a half-Lambert and the result divides by the texel's rebalancing coefficient. Non-directional scenes keep the flat colour. For reference, Unity's official HLODSystem was studied for its texture/lightmap combination — it contains none (HLODs render unlightmapped through light probes and realtime light); the authoritative compositing lives in the render pipeline's runtime shaders, which is what this ports. +- **Remesh & Bake: the tangent frame is regenerated after the UV cut, in meshoptimizer.** The unwrap output now carries per-vertex tangents from `meshopt_generateTangents` (MikkT-compatible, with mirrored-UV corner conflicts splitting their vertex), computed over the final atlas layout, so the bake, the 3D preview and the saved FBX all encode against one basis instead of Unity's separate recalculation (which stays only as a fallback). Vertex normals are likewise regenerated in C# from the split geometry for **every** hard-edge mode — crease splits and chart borders are already vertex splits, so the weighted accumulation smooths inside each split group and stays hard across creases and island borders alike. A new **Normal weighting** option (Blender Weighted Normal modifier analog) selects the accumulation weight: *Face area* (meshopt's own, default), *Corner angle*, or *Face area × corner angle*; the stale disablement of the smoothing slider in island modes is gone. Native ABI is now 3 with a sixteen-float unwrap vertex layout (position, normal, UV0, tangent) — the plugins must be rebuilt (**Build Native Libraries**, the auto workflow does it on push) and Unity restarted. +- **Remesh & Bake: Normal smoothing now applies after UV generation and works with every hard-edge mode.** Previously the value was passed into the native normal generation *before* the unwrap, where the UV-island hard-edge rebuild (`SmoothWithinSplitVertices`) overwrote the result — leaving the slider dead in the default mode. The pass now runs in C# on the split unwrap output for all modes (a port of meshoptimizer's alignment-weighted smoothing kernel: up to 10 passes, each averaging normal deltas over mesh edges with aligned neighbours pulling more and opposing ones not at all). Because crease splits and chart borders are already materialized as vertex splits by that point, and mesh edges never cross a split, smoothing flows along the surface and stops at every hard edge regardless of its source. The native call now receives `smoothing: 0`; the ABI and binaries are unchanged. + ### Added +- **Remesh & Bake: bounding-box source shape — a far-LOD box proxy.** The remesh stage's new *Shape* selector picks *LOD0* (voxelize the capture, as before) or *Bounding box*: every captured model is replaced by its axis-aligned bounding box, and THAT mesh flows through the rest of the pipeline unchanged — unwrap gives the box its own atlas, and the bake projects the ORIGINAL captured geometry's materials and lighting (Materials and Beauty alike) onto the box's faces. The weld lane produces one box for the whole model; keep-hierarchy produces one box per node. Voxel controls disable in box mode, and the stage cache key covers the shape. +- **Remesh & Bake: Beauty mode — the object baked as the player sees it, into one lit texture.** A new *Bake mode* selector folds the scene's lighting into the BaseColor map and saves an **Unlit/Texture** material with that single texture (the other maps still export alongside for reference): realtime/mixed direct light with hard shadow rays through the source BVH, the renderer's baked lightmaps (captured per face at its UV2 with the renderer's scale/offset, RGBM/HDR decoded at readback), ambient (flat, trilight, or the ambient probe evaluated by Unity itself into a direction grid the bake workers interpolate) and reflection probes (equirectangular readbacks, roughness-lerped toward the probe average, simplified Standard Fresnel). Baked-only lights are skipped so lightmapped faces count nothing twice; the lightmap is the base there (it already contains albedo × GI × baked emission). Specular is view-dependent — it bakes for the scene view camera's position at bake time. Beauty previews render unlit, matching the saved material. Works in both the weld and keep-hierarchy lanes. +- **Remesh & Bake: LOD0-only source filtering.** A *LOD0 only* toggle (default on, remesh stage) skips meshes named `Name_LOD1` and higher wherever they sit — LODGroups already contribute LOD0 only, but machines that emit LOD levels as separate nodes produced duplicate captures. +- **Remesh & Bake accepts skinned sources.** `SkinnedMeshRenderer` geometry is baked at its current pose into a fresh runtime mesh (renderer-local space, no transform scale — the shared root-local capture applies the transform exactly once, like a static mesh under the same node) and captured with its skinned normals/tangents and materials. Pose the model the way you want it baked; animation is not followed. +- **Remesh & Bake: normalized saves and a `remesh` output folder next to the source.** *Normalize size (saved at scale 1)* (default on) bakes the source root's world scale into the saved geometry, so the model keeps its real size with an identity transform regardless of how the source is scaled — positions scale component-wise, normals and tangents take the inverse (inverse-transpose of a diagonal matrix) and renormalize, and a mirroring determinant flips the triangle winding. Off keeps root-local geometry and carries the scale on the saved transform instead (the previous behavior). Saves also no longer ask for a folder when the source resolves to an imported asset: output goes to a `remesh` subfolder next to the source's FBX/prefab/mesh (created if missing), with the folder picker remaining only for scene-only sources. +- **Remesh & Bake diagnostic log (RemeshDiag category)** — every bake prints its health counters to the Console when the `RemeshDiag` log filter is on: welded cage positions and split copies (how many vertices the UV/crease splits duplicated), one-sided border normals with the max cage deviation, nearest-fallback projection samples, missed texels, and the normal map's tilt statistics (mean/max angle from flat, count of texels leaning >45°). A map where >5% of texels lean >45° at a >30° mean tilt additionally logs a warning pointing at hard-edge mode / projection distance / cage fit — the numeric signature of the island-border artifact bands fixed this release. The category bit slots into the existing Log filters panel automatically. +- Experimental Remesh & Bake pipeline with new UV0, material transfer and isolated native jobs. +- **Remesh & Bake: embed textures in the exported FBX** — a save-time toggle (default on) passes `EmbedTextures` to the FBX exporter so the baked maps travel inside the binary FBX instead of being linked by absolute path (the exporter's default link triggered "absolute reference to this texture file" warnings and made the FBX non-portable). Turn it off for a lean FBX that links the exported maps on this machine; embedding adds the map sizes to the file (the float EXR emission map alone is 16 bytes per texel). +- **Remesh & Bake stages and previews** — the tab runs Voxel remesh → Simplify → Normals & UV → Bake as separate stages with their own settings, each re-runnable without repeating earlier ones (stale stages are marked and refreshed first). A right-hand preview shows an orbitable 3D view of any stage (wireframe, shading, baked base color, vertex colors), the UV layout with tinted islands over the baked base color, and every baked map. Simplification is error-driven and adaptive by default (flat areas collapse, detail keeps density) with optional light/strong regularization, fold preservation and small-part pruning; the triangle count is now a stopping point, not required. Hard edges: smooth, crease angle, UV island borders, or both. xatlas island and packing options are exposed. Baking supersamples 1/4/9/16 samples per texel with conservative edge coverage and can transfer source vertex color and vertex alpha to the result mesh. Native remesh ABI is now 2 (`meshLabVoxelRemesh`, `meshLabSimplify`, `meshLabUnwrap`); rebuild the plugins. +- **Remesh & Bake saves the model as FBX** — with `com.unity.formats.fbx` installed, "Save FBX, maps & prefab…" exports the remeshed mesh (generated normals including UV-island hard edges, UV0, tangents, transferred vertex colors) as a binary FBX named after the source, plus a prefab that instantiates it with the curated material; the mesh `.asset` is no longer produced in that mode. The FBX importer of the result is set to `materialImportMode: None` so no duplicate MaterialDescription material is generated. Without the package the previous `.asset` + prefab output remains, with an inline hint to install it. New-geometry FBX writes are documented as the single out-of-core carve-out in `Documentation~/FBX_PIPELINE_CHECKLIST.md` §12. - **Per-target Transfer diagnostic summary** — `GroupedShellTransfer.TransferCore` emits a single Info line at the end of every transfer when `UvtLog.Category.TransferDiag` is enabled in the Log filters panel. Reports source/target shell counts, `shellsMatched` / `shellsRejected`, `ShellStatus` histogram (Accepted/Degraded/Poor/Rejected/Unmatched), method histogram (interp/xform/merged), `fragmentsMerged`, `dedupConflicts`, `shellsOverlapFixed`, `consistencyCorrected`, mean/max 3D match distance, and topology iterations/fixed/capHit. Lets the identity-sanity and per-LOD ratio sweep checklists in `Documentation~/TRANSFER_LOD_QUALITY_PLAN.md` be run without trawling verbose per-shell output. New category bit slots into the existing Log filters UI automatically. - **`UvProgress` service** (`Editor/Framework/UvProgress.cs`) — central non-modal progress reporting. Routes status to `UnityEditor.Progress` (Background Tasks panel) plus an inline strip drawn at the bottom of the hub window. Supports nested scopes, phase labels, indeterminate/determinate fractions, cooperative cancellation via `UvProgress.CancelRequested` (`Volatile.Read`-backed `_cancelFlag` so background `Task.Run` work observes user-cancel reliably across the memory barrier), a thread-safe `ReportFromBackground` for `Task.Run` callers (with `Interlocked.Exchange`-guarded snapshot/clear so a racing writer can't lose an update; the `EditorApplication.update` pump is hooked once on assembly load from the main thread via `[InitializeOnLoadMethod]`), and a `Last` outcome shown while idle. - **Inline progress strip in `UvToolHub`** — sits at the bottom of the window as a status bar. Reserves fixed height unconditionally so toggling active state doesn't displace any layout. Shows title · phase · detail · elapsed in distinct columns with a Cancel button pinned to the right; while idle displays `✓ / ✗ Last-operation · 12.3s`. Marquee animation for indeterminate fractions; orange tint while cancelling. @@ -33,6 +113,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - **`LightmapTransferTool.ExportVertexColorsToFbxAs(sourceFbxPath, outputFbxPath, entries, uvChannelOverride)`** — public entry point that writes a new FBX next to (or anywhere relative to) the source without mutating the source importer, scene mesh bindings, or working copies. ### Changed +- **Remesh & Bake: the 3D preview applies the baked normal map.** A new **Bump** toggle in the 3D view (default on) shades the result stage with the baked tangent-space normal map — the same data the saved material gets — instead of vertex normals only. With *Hard edges = UV islands* the result's vertex normals are smooth by design and all detail lives in the normal map, so the vertex-only preview (and the vivid pink/cyan atlas under **Maps ▸ Normal**) read as "normals not transferred" although the bake is intact. The FBX save also pins `importNormals: Import` and `importTangents: Import` on the result importer, so Unity does not recalculate MikkT tangents whose per-vertex handedness can disagree with the frame the map was baked against (a green-channel flip on parts of the baked map). +- **Remesh & Bake: hard edges default to UV islands and settings persist.** `RemeshSettings.hardEdges` now defaults to `UvIslands`: on coarse organic decimations an angle crease leaves nearly every edge split (a fully faceted look) and feeds xatlas the crease-split normals as seams, which shatters the atlas into sliver charts. Stage settings are also saved to EditorPrefs across domain reloads and tab switches instead of silently resetting to defaults between runs. +- **meshoptimizer v1.3** — `Native~/CMakeLists.txt` now pins the v1.3 release (`9e1f07b1`, 2026-09-25) instead of the 2026-09-10 remesh snapshot (`ff4a519a`). v1.3 drops the no-op `meshopt_RemeshThicken` and renumbers `meshopt_RemeshShell` / `meshopt_RemeshSolve`; the remesh bridge maps its own flag bits by name, so the C# ABI (`meshLabRemeshVersion() == 1`) is unchanged, and the native test now fails if the shell flag stops reaching meshoptimizer. The plugin binaries are rebuilt by **Build Native Libraries**. - **Vertex AO tab renamed to Vertex Color Baking.** `VertexAOTool` → `VertexColorBakingTool`, `ToolId` `vertex_ao` → `vertex_color_baking`. Asset GUID preserved so existing references stay intact. AO functionality is unchanged and reachable via the new toolbar at the top of the tab. Note: `UvToolHub.SelectToolById` is fed from persisted state, so a stored deep-link to the old `vertex_ao` id now falls back to the first tool. - **Mesh Lab window menu path** moved from `Tools ▸ Mesh Lab` to `Tools ▸ Mesh Lab ▸ Open Mesh Lab`, making room for `Tools ▸ Mesh Lab ▸ Validators/*`. Existing keyboard shortcuts bound to the old path need rebinding. - **Log prefix** `[LightmapUV]` → `[MeshLab]`, and the `UvtLog` EditorPrefs keys `LightmapUvTool_LogLevel` / `LightmapUvTool_LogCategoryMask` → `UnityMeshLab_*`. Stored log-filter preferences reset once. @@ -58,6 +141,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### 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. +- **Process invocation and gallery paths (Mimosa pre-commit gate)** — `UvToolHub.StartGitBinary`/`RunGit` and `BenchmarkSweep.TryRunGit` no longer assemble git command lines as strings: every argument reaches git as one verbatim `ProcessStartInfo.ArgumentList` token, so an asset path interpolated into a revision like `main:` cannot inject extra git options. `Tools~/build_gallery.py` routes every write through `write_gallery_file`, which resolves the output directory, requires the target to resolve to a direct child with the exact sanitized basename (`safe_model_filename` strips everything outside `[A-Za-z0-9._-]` from CSV-derived `lodGroup` names), and refuses anything that would escape the gallery directory. - **CI and build tooling** — release/version workflows no longer interpolate untrusted values straight into inline Python; the native build workflow pins every action to a verified SHA and defaults to a read-only token, with `contents: write` scoped to the publish job alone, which still commits the freshly built binaries into `Plugins/` automatically on push; `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). @@ -65,6 +149,14 @@ Consolidated hardening sweep — incorporates PRs #122–#123, #125–#129, #131 - **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 +- **Remesh & Bake: zero normals from the unwrap now self-heal and are reported.** A degenerate corner group (zero-area faces) can come back from the native unwrap with zero-length normals, which zero the bake's ray directions and tangent frames at once — the diagnostic signature is every cage deviation reading 90°, ~100% of projection samples falling back to nearest-point search, and a normal map whose blue channel sits at 128 (tilt ≈ 90°) everywhere. The UV stage now detects zero normals, rebuilds all of them from face geometry (`SmoothWithinSplitVertices`) with a warning, and the RemeshDiag summary reports the zero count plus a dedicated warning when more than 80% of samples fall back to nearest-point search. +- **Remesh & Bake: saved model now matches the source's world size.** The result geometry is captured and baked in the source root's local space (capture combines renderers via `root.worldToLocalMatrix`, with inverse-transpose normals and winding sign — source and target share one space, so projection scale was always consistent), but the export temporary carried an identity transform, so a source with `lossyScale != 1` (FBX file scale, artist-scaled GameObject) saved at root-local size — a different size than the original. The exported FBX node (and the `.asset` fallback prefab) now carries the source root's lossyScale. The RemeshDiag summary additionally reports the source/target bounds-diagonal ratio and warns when it drifts from 1 beyond 10%, catching future scale regressions at bake time. +- **Remesh & Bake: normal-map artifact bands around every UV island in hard-edge modes.** With *Hard edges = UV islands* (or *+ angle*), `SmoothWithinSplitVertices` leaves each chart-border vertex copy a one-sided normal, and the baker cast its projection rays along exactly those normals — the ray landed on a displaced source point, so a band of garbage texels formed along every island border (on an 85-island atlas: noise everywhere). Rays are now cast along a smooth welded cage direction (area-weighted face normals averaged across bit-identical coincident vertices — the direction the native crease=π path produces before the split), while the tangent frame keeps the hard vertex normal the result mesh shades with, so encode/decode still cancel and hard island borders survive intact. Verified on a synthetic displaced-sphere repro (exact port of the bake math): mean angular error drops from 3.2° to 0.96° and >45° outliers fall 8× to the level of the previously-working smooth-border path. +- **"Save Mesh Assets" no longer throws on unparsable mesh names** — `SaveAll` sanitized mesh names before generating asset paths; a name with a character that is invalid in file names made `GenerateUniqueAssetPath` return an empty string and `CreateAsset` threw "path is empty" mid-OnGUI, corrupting the hub layout state for the event. A missing/uncreatable output folder now aborts with a readable error instead of cascading. +- **FBX re-saves no longer triangulate quad meshes** — the isolated-export core (`ExportFbxIsolatedCore`, the path behind Vertex AO saves and narrow-intent UV/channel overwrites) enables `keepQuads` on the source importer in Phase 1, so the clone it serializes keeps the FBX's original polygon topology and Unity's FBX exporter writes quads back. `keepQuads` only reshapes the index buffer (vertex order/count untouched), so the snapshot/clone vertex-count contract holds, and the setting persists like `generateSecondaryUV=off` so the re-saved file is not re-triangulated on the next import. Variant exports (`ExportVertexColorsToFbxAs`) toggle `keepQuads` only for the clone reimport, restore the source importer afterwards (also on failure), and pin `keepQuads` on the new variant file's own importer. The wide LOD-rebuild path already locked `keepQuads` at export time; its export meshes still take topology from the tool's computed meshes, so the first wide export of a freshly loaded (triangulated) import writes triangles — from the next reload on, quads survive. N-gons (>4 vertices) still import triangulated (`keepQuads` covers quads only). +- **Remesh & Bake capture and save** — Read/Write-disabled source meshes are read from the imported asset instead of an empty `Instantiate` clone ("Not allowed to access uv"), shaders other than Standard/URP Lit bake from common property names with a warning instead of aborting, and Save refreshes a folder the panel just created instead of failing with "Could not create result folder". +- **Remesh & Bake tab** — Source root now follows the selection (a LOD child resolves to its LODGroup) instead of being read once when the tab first opened, which left Generate & Bake disabled after selecting a model as the status text asks. Generate & Bake and Save end the IMGUI event after they change the sidebar or open the modal folder panel, so the first run no longer logs a `Getting control N's position` layout error. Cancel reports that it is waiting for the current phase, and the result line reads the index count instead of copying `Mesh.triangles` on every GUI event. +- **`MeshoptNative` simplify flags** — `SimplifySparse` / `SimplifyErrorAbsolute` were swapped relative to `meshoptimizer.h` (`Sparse = 2`, `ErrorAbsolute = 4`). No caller passed either flag, so behaviour is unchanged. - **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). diff --git a/CLAUDE.md b/CLAUDE.md index 41239920..34e56aca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,12 +31,19 @@ See `AGENTS.md` for shared rules that apply to all AI agents. - **Entry point:** `Editor/Framework/UvToolHub.cs` — main EditorWindow - **Context:** `Editor/Framework/UvToolContext.cs` — shared state - **Tools:** `Editor/Tools/` — each implements `IUvTool` +- **Geometry:** `Editor/Geometry/` — `MeshGeometry` (face normals, welding, sample directions, barycentrics) and `GpuReadback` (the one GPU → CPU texture read); spatial queries in `Editor/TriangleBvh.cs` / `TriangleBvh2D.cs`. Use these; never add a tool-local copy +- **LOD hierarchy:** `Editor/Mesh/LodHierarchy.cs` — a LODGroup from its hierarchy (`CollectByName`, `LodsFromNames`, `RebuildFromNames`, `CreateFromRenderers`, `CreateFromSiblings`, `Compact`, `RootRendererIsLod0`, `MoveRootMeshToChild`, `SortChildrenAsLods`, `AddColliderFromCollisionMesh`, `AssignCollider`); `LodGroupUtility` is the component half (recreate, apply, transition normalisation) +- **Vertex channels:** `Editor/Mesh/VertexChannels.cs` — a scalar per-vertex channel (`AOTargetChannel`: colour component or UV component) decoded, read, written (whole mesh or one submesh), levels/blend, greyscale preview colours, fill/snapshot/restore of `colors32`. Never decode the enum or poke `colors32`/`GetUVs` for a scalar channel in a tool +- **UV:** `Editor/Uv/UvTopology.cs` — index-space UV layout topology (channel read, boundary edges, unique edges, point-in-triangle, shell vote, face→shell, shell data, UDIM tiles); the canvas, 3D layer and viewport cache its results, never recompute them locally +- **Bench:** `Editor/Bench/` — `SweepRunner` (matrix → cells → pipeline runs through `ISweepHost`), `BenchmarkRunner` (suite cases → spawn → techniques, through `IBenchmarkHost`), `BenchmarkRecorder`, `BenchmarkSweep` (aggregate, winner, gallery, manifest, archive), `TestSuiteAsset`, `CsvUtil`. A tab implements the host interfaces; it never loops over cells itself +- **Diagnostics:** `Editor/Diagnostics/` — `DebugUi` (the one Show Debug UI gate `Enabled`, the DEBUG banner, the log-filter block; `IUvToolDebugOnly` marks a tab the hub hides while the setting is off), `BakeHealth` (a Remesh & Bake node's health as a report: summary, scale check, warnings), `HierarchicalDiag`, `FbxMetricsExporter`. The Diagnostics tab (`Editor/Tools/DiagnosticsTool.cs`) hosts the sweep, the benchmark, the log filters and the reports; never read `showDebugUI` or draw a debug banner in a tool +- **Assets:** `Editor/Assets/` — `TextureAssets` (a texture from pixels, PNG/EXR encoding, file writes, image read-back, map importer configuration), `FbxExport` (every FBX write: the isolated channel re-save, the LOD-rebuild hierarchy passes, the atomic write, the post-reimport relink) and `SidecarStore` (the `_uv2data.asset` sidecar: UV2 entries, collision hulls, tool settings). Tools never call `ModelExporter` or open a `Uv2DataAsset` themselves - **Native:** `Plugins/` binaries, `Native~/` C++ source - **Sidecar:** `Uv2DataAsset` persists UV2/collision data alongside FBX ## Key Patterns -- LOD siblings: `baseName[_-\s]LOD{N}` regex, case-insensitive -- Mesh group key: `UvToolContext.ExtractGroupKey()` strips LOD/COL suffixes +- Names: `Editor/Mesh/MeshNaming.cs` is the only reader of the naming rules — LOD suffix `baseName[_-\s]LOD{N}` (case-insensitive, index < 8), collision suffixes (`_COL`, `_COL_Hull{N}`, `_Collider`, `_Collision`), pipeline suffixes, group key. Never write a LOD/COL regex in a tool +- Mesh group key: `MeshNaming.GroupKey()` (forwarded by `UvToolContext.ExtractGroupKey()`) strips LOD/COL suffixes - FBX export: clone prefab → replace meshes → add LOD/COL → `ModelExporter.ExportObjects` - Sidecar: generate → save to `_uv2data.asset` → export to FBX (non-destructive) diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 3bfd25c3..96007bfb 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -309,7 +309,7 @@ ### Sidecar collision entries — не удалять при FBX overwrite - Старый код удалял весь sidecar (включая collision entries) после overwrite. - При повторном экспорте collision meshes были недоступны (non-readable FBX sub-assets). -- **Решение**: `ClearUv2EntriesForFbxPaths()` — удаляет только UV2 entries, сохраняет collision entries. +- **Решение**: `SidecarStore.ClearUv2Entries()` — удаляет только UV2 entries, сохраняет collision entries. ### Convex hull triangle indices — глобальный offset - `SaveToSidecar()` хранит triangle indices как flattened array. Для multi-hull convex decomposition индексы должны быть rebased к глобальному vertex offset. diff --git a/Documentation~/FBX_EXPORT_MODERNIZATION.md b/Documentation~/FBX_EXPORT_MODERNIZATION.md index d16927b1..57d75bab 100644 --- a/Documentation~/FBX_EXPORT_MODERNIZATION.md +++ b/Documentation~/FBX_EXPORT_MODERNIZATION.md @@ -16,6 +16,11 @@ both pipelines (sidecar on/off) and .meta file semantics. ## Current export flow (as of PR #75) +> The mechanics named below have since moved out of the tool: the hierarchy passes, +> the export mesh, the write and the relink are `Editor/Assets/FbxExport.cs`, the +> sidecar reads and writes are `Editor/Assets/SidecarStore.cs`; `LightmapTransferTool` +> keeps the dialogs, backups, importer lock and sequencing described here. + `LightmapTransferTool.cs:908-1284` `ExportFbx(bool overwriteSource)`: 1. Find source FBX path via `ctx.MeshEntries[*].fbxMesh` asset path or diff --git a/Documentation~/FBX_PIPELINE_CHECKLIST.md b/Documentation~/FBX_PIPELINE_CHECKLIST.md index f9de2e4d..a460beac 100644 --- a/Documentation~/FBX_PIPELINE_CHECKLIST.md +++ b/Documentation~/FBX_PIPELINE_CHECKLIST.md @@ -305,6 +305,55 @@ see §9). write itself) is the core's responsibility. Call sites never touch these sets directly. +### Quad preservation (`keepQuads`) + +A re-save must not triangulate a quad mesh. Unity meshes carry topology in +their index buffer, and the FBX exporter writes whatever topology the +serialized mesh has — so the clone that reaches `ModelExporter` must be +imported with `keepQuads = true`: + +* **Isolated core** — Phase 1 enables `keepQuads` on the source importer + (alongside `isReadable`) before the clone is loaded. `keepQuads` only + reshapes the index buffer (4 indices per quad instead of two triangles); + vertex order and count are untouched, so the snapshot/clone + vertex-count contract holds. The value persists after export (like + `generateSecondaryUV = off`) — restoring `false` would triangulate the + just-written quad FBX on the next import. +* **Variant exports** (`ExportVertexColorsToFbxAs`) — the source importer + must end the export unchanged, so `keepQuads` is toggled on only for the + clone reimport and restored by `FbxExport.ImporterRestoreScope` at method exit — + success, every early return and failure alike, a throwing Phase 1 reimport + included: the scope is created before Phase 1 and told about each change + before the reimport that applies it (the same scope puts `isReadable` back + for source re-saves). The new variant file's own importer + gets `keepQuads` pinned in Phase 4 so its project view matches the file. +* **Wide LOD-rebuild path** — already locks `keepQuads` via + `PrepareImportSettings(lockForFbxOverwrite: true)` before export. Its + export meshes take topology from the tool's computed result meshes, so + the first export of a freshly loaded (triangulated) import still writes + triangles; once the lock has persisted and the model reloaded, every + later export keeps quads. +* **Known limitation** — `keepQuads` covers quads only. N-gons + (>4 vertices per face) still import triangulated, and generated LODs + (meshoptimizer output) are triangle meshes by construction. + +### New-geometry export (Remesh & Bake) — the one out-of-core write + +`RemeshBakeTool.Save()` exports a **brand-new mesh** (voxel remesh result) +into a **uniquely created folder**, gated by the same +`LIGHTMAP_UV_TOOL_FBX_EXPORTER` define. It calls +`ModelExporter.ExportObjects` directly because the isolated core is a +*re-save* pipeline: its snapshots key on mesh name and require the same +vertex count, so it structurally cannot carry re-topologized geometry, +and there is no source FBX whose untouched channels must survive. +Safety comes from the fresh-path guarantees instead: the target file +never pre-exists (a new unique folder), the output is verified +non-empty, and any failure rolls the whole generated folder back. +The FBX importer of the result is pinned to `materialImportMode = None` +so the importer does not duplicate the curated material that ships +next to the FBX. Do not use this carve-out for anything that mutates +an existing FBX — that path must go through the core. + ### What "rework, not parallel" means in code review Reject PRs that: diff --git a/Documentation~/REMESH_AND_BAKE.md b/Documentation~/REMESH_AND_BAKE.md new file mode 100644 index 00000000..317f25d0 --- /dev/null +++ b/Documentation~/REMESH_AND_BAKE.md @@ -0,0 +1,480 @@ +# Remesh & Bake (experimental) + +Build a new static low-poly mesh, new UV0 atlas, and reproject source materials in +the **Remesh & Bake** tab of **Tools → Mesh Lab → Open Mesh Lab**. The original +meshes, materials, importers, scene objects and LODGroups are not replaced. + +## Workflow + +The tab runs four stages. Each stage has its own settings and button; running a +stage first brings every earlier stage up to date (missing output or changed +settings, marked "settings changed" in its header) and clears everything after it. +**Run all stages** re-runs the whole chain. The right panel previews the result: + +- **3D** — a vertical list for the narrow right column: the **Stage** (source, + voxel remesh, simplified mesh or final result) shown in the canvas's 3D view, + then what is specific to this tool — **Baked base color** and **Baked normal + map** on the result, **Trim mask** on the remesh stage, **Cage shells** on the + result. Wireframe, the shading modes (vertex colours, normals, tangents, UV + channels), the UV fill mode, the island borders (**Bdr**) and spot picking are + the canvas's own controls (status bar and the 3D view's shading row) and + apply to the stage mesh like to any other mesh: with the result stage shown, + the UV layer draws the atlas's islands and their borders on the model in 3D. + The normal-map toggle applies the baked tangent-space normal map to the + result stage only (earlier stages have no tangents); with *Hard edges = UV + islands* the vertex normals are smooth by design, so this toggle is what + shows the transferred detail — the vivid pink/cyan atlas in **Maps ▸ Normal** + is that same detail in tangent space, not corruption. + **Trim mask**, at the *Remesh* stage, shows the untrimmed remesh coloured + by what *Trim to source surface* did with each face: green kept, red the back + of a sheet (a source face within reach faces the other way), orange a rim or a + face with no source within reach — so what the cut removes, and why, is + visible before the simplifier touches it. + The 3D panel also reports the atlas: island count and texel usage. +- **Maps** — each baked map (base color, normal, metallic/smoothness, occlusion, + emission). + +The canvas's **UV** mode (the UV | 3D switch at its bottom) shows the result's +atlas once Normals & UV ran — the same shells, wire, border, spot picking and +status line as any mesh — over the baked base color (or the checker when the +canvas asks for it), with an **Islands** fill mode that tints every UV shell. +Before the UV stage the canvas shows the selected model as usual. + +1. **Source.** Select a model root. **Source root** follows the + selection: a LOD child resolves to its LODGroup, and selections without a + MeshRenderer (lights, cameras) are ignored. An object dragged into the field + holds until the selection changes. Active/enabled MeshRenderers under it are + combined in root-local coordinates; LODGroups contribute LOD0 only, collision + nodes are excluded, and **LOD0 only** (default on) skips meshes named + `Name_LOD1` and higher wherever they sit. **Shape** picks what the remesh + stage builds from the capture: *LOD0* voxelizes the geometry (the default); + *Bounding box* replaces every captured renderer with its own oriented box — + measured over the renderer's geometry along the renderer's authored axes and + placed back in the capture space, so a yawed building keeps a yawed box + instead of the inflated axis-aligned one (weld: one box per renderer, all in + one mesh; keep-hierarchy: one box per node; a flat renderer gets a minimal + slab thickness); *Hull* runs the same geometry + through a coarse voxel pass (*Hull resolution*, solid fill, no shell fit) and + a strongly regularized simplification down to *Hull triangles*, giving a + closed, rounded blob that follows L- and T-shapes without the box + decomposition's guesswork. Both proxy shapes bake the original's materials + and lighting through the proxy projection described under **Bake**. + **Exclude parts smaller than** (a fraction of the capture diagonal) and + **Exclude rods thinner than** (in voxel cells) run before any shape over the + connected pieces of the capture — the connected components of the + position-welded triangle graph, so a pipe goes even when it shares a mesh + with the wall. A piece goes when its extent is under the size fraction, or + when its cross-section — the two smaller of its three extents along its own + principal axes — is under the rod threshold, a cell being the model's + longest side divided by the voxel (or hull) resolution: that is the section + the grid cannot carry anyway. Thin is not the criterion: a gate leaf, a + glass pane or a decal has one thin extent and stays; pipes, cables, railings + and bolts have two and go. The stage status reports how many pieces went, + and a filter that would remove everything is skipped with a warning instead + of producing an empty remesh. **Highlight capture in Scene** paints the + source in the Scene view as the remesh stage will see it, before any stage + runs and live with the filter sliders: green = captured, orange = dropped + as a small part, red = dropped as a rod, grey = renderer the capture skips + (LOD1+, collision, disabled). It is a geometry-only capture (no material or + texture reads) drawn over the originals with a depth offset; the sidebar + shows the triangle counts per class. + SkinnedMeshRenderers are baked at their + current pose (skinning re-evaluated first — in edit mode it can be stale + and bake every part at its authored origin) and then captured like static + meshes; pose the model the way you want it baked. Every contributing submesh + needs UV0. Read/Write-disabled imports are read through MeshData; the source + importers are never touched (no reimports). +2. **Voxel remesh** — voxel resolution (4–256), fit to source surface, two-sided + shell. Higher resolution preserves smaller gaps but produces a denser, + uniform intermediate mesh. **Trim to source surface** (default on) masks the + result against the source: the voxelizer closes every surface, so an open + sheet (a wall, a roof plane, a curtain) comes back as a slab with a front, a + back and rims — and because the remesher fits its vertices onto the input + surface, the front and the back lie ON the sheet, a zero-thickness + double-sided surface rather than a cell-thick slab. A remesh face stays only + when a source face lies within two voxel cells of it whose normal points the + same way; the back of a one-sided sheet (opposite normal) and the slab's + rims (perpendicular) have none and go, a closed source is left whole, and a + source that is double-sided where it matters keeps both sides. A source + wound inside out is judged by its flipped normals; when neither reading keeps + a tenth of the remesh, nothing is trimmed and a warning says so. The kept + sheet is then re-wound to one consistent orientation per connected piece + (neighbours must traverse their shared edge in opposite directions; the + majority of each piece keeps the side that agreed with the source), so a + source modeled with arbitrary winding — common under a two-sided material — + comes out orientable. The status reports how many faces went and how many + were re-wound, and the Remesh stage's **Trim** toggle colours the untrimmed + remesh by class. Turn it off to keep the slab, for instance with *Two-sided + shell*. **Source backfaces** decides which source faces count from behind as + well: *From materials* (default) marks a material two-sided when its cull + mode property (`_Cull`, `_CullMode`) is Off or a double-sided switch + (`_DoubleSidedEnable`, `_TwoSided`, `_DoubleSided`) is on; *Always* treats + every face as two-sided (for a `Cull Off` written into the shader itself, + which no property reveals); *Never* keeps only the front of every face. A + two-sided source still yields ONE sheet — the result material renders both + sides of it instead (URP Lit: Render Face Both; Standard has no two-sided + mode, and the save says so) — the bake's front-face filter accepts such a + face from either side, and the winding probe ignores it. The setting marks + both the remesh and the bake stage stale. +3. **Simplify** — quadric simplification of the voxel mesh. It collapses the + cheapest edges first, so with *Regularize = None* flat areas reduce to a few + large triangles while curved or detailed areas keep their density. *Maximum + error* is relative to the mesh size. *Stop at triangles* ends simplification + at that count or at the error limit, whichever comes first; 0 lets the error + alone decide. *Light/Strong* regularization evens out triangle sizes instead. + *Preserve folds* keeps sharp creases; *Remove small parts* drops tiny + disconnected pieces. Turn *Simplify* off to unwrap the voxel mesh as is. +4. **Normals & UV** — *Hard edges* (defaults to *UV islands*): + - *Smooth* — no hard edges. + - *Angle* — edges sharper than *Crease angle*. On coarse organic + decimations most edges exceed the crease, which reads as fully faceted + and also feeds xatlas the crease-split normals as seams, shattering the + atlas into slivers — prefer *UV islands* for baked results. + - *UV islands* — hard exactly along UV island borders, smooth inside each + island (the usual choice for baked normal maps). + - *UV islands + angle* — both. + + Whichever mode is active, the vertex normals and tangents are regenerated + **after** the UV cut: normals accumulate weighted face normals over the split + geometry (smooth inside every split group, hard across creases and island + borders alike), and tangents come from meshoptimizer's MikkT-compatible + generator over the final atlas layout — one basis for the bake, the preview + and the saved mesh. *Normal weighting* selects the accumulation weight + (Blender Weighted Normal analog): face area, corner angle, or both. + + *Normal smoothing* (0–10) is applied **after** UV generation, so it behaves + the same whichever hard-edge mode is active: smoothing flows along the + surface through mesh edges and stops at every hard edge — crease splits and + island borders alike (edges never cross a vertex split). With it applied + before the unwrap, the island hard-edge rebuild discarded it, leaving the + slider dead in the default mode. + + Stage settings persist across domain reloads and tab switches (EditorPrefs); + *Run all stages* still re-runs everything with the restored values. + + *Islands & packing* exposes the xatlas chart options: max cost (lower = more, + smaller islands), normal deviation, hard-edge seam weight (islands prefer to + break on hard edges), straightness, roundness, iterations, max island area and + border length (source units, 0 = unlimited), rotation, 4×4 block alignment and + brute-force packing. Texture size and padding set the atlas. +5. **Bake** — **GPU projection** (default on where compute shaders exist) runs + the geometry queries — the projection rays and the nearest-point fallbacks — + on the GPU through `Shaders/BvhQueries.compute`, the same BVH as the CPU with + the same filters and the same hit records, so the result is the one the CPU + path produces, usually several times faster on large atlases; the atlas is + processed in bands of rows (sample requests built on workers, answered in one + dispatch per batch, evaluated on workers), and the status says which path + ran. Projection distance (fraction of the source bounds diagonal), + *Samples per texel* (1, 4, 9 or 16; stratified supersampling that also covers + texels only partly inside an island, for clean chart edges and less aliasing) + and vertex color transfer: *Vertex color (RGB)* and *Vertex alpha* copy the + source vertex colors, interpolated at the nearest source surface point, onto + the result mesh independently. Missed covered texels are magenta, not silently + patched with unrelated material data. Projection rays are cast along a smooth + welded "cage" direction, not the vertex normal: UV-island hard edges leave + chart-border normals one-sided, and rays along them would sample a displaced + source point, baking artifact bands around every island. The tangent-space + normal map is still encoded against the vertex normal the result mesh shades + with, so hard island borders keep their crisp silhouette while the interior + stays clean. The cage is built **per face corner and per side**: the corners + meeting at one position are clustered by the hemisphere their face normals + share (within 120°), and only one cluster's corners are averaged (face area × + corner angle) and Laplacian-smoothed together over the cluster connectivity + (**Cage smoothing**, 0–10 passes, 2 by default). UV-chart and crease splits + weld back into one smooth direction as a plain cage does, but a double-sided + sheet — the wall of a non-closed source, thinner than a voxel cell and + collapsed to zero thickness by the simplifier, both windings on the same + vertices — keeps a front side and a back side, where a position weld would + sum two opposite normals to nothing and normalize the noise (rays leaving at + 180° from their face, preview shells spiking across the whole model). Every + corner direction is checked against its own face and falls back to the + unsmoothed side, then to the face normal, so no ray starts behind the surface + it belongs to. **Fit cage to source** (default on) replaces the one global + ray travel with a per-side reach: each side casts along its direction both + ways (then asks for the nearest point) to measure where the source actually + is, doubles that for oblique surfaces, clamps it between 1× and 8× the + projection distance and smooths it over the side connectivity, never below a + side's own need — a cage that hugs the source where the decimation stayed + close and opens where it drifted, so fewer texels miss without deep rays + everywhere. Rays only accept source triangles facing them (front-face + filter): a plain closest-hit ray travels twice the reach through the target + and pierces thin walls, sampling the far side's texture as periodic mirrored + patches; the filter's orientation comes from a probe that looks at the + source from OUTSIDE — rays cast from a sphere around it toward its centre + meet an outer surface first, and that triangle's winding against the ray + gives the answer whatever the target's density — and it stays off when + fewer than 70% of the rays agree (open sheets, mixed winding). Two-sided + source faces (see *Source backfaces*) pass the filter from either side and + cast no vote in the probe. The 3D view's + **Cage** toggle (the right sidebar's 3D panel drives the canvas's shared 3D + view — switch the canvas to **3D** at its bottom centre) draws the projection + limits — every corner pushed ±its reach along its cage direction, one line + per welded side pair, orange for the outer (ray origin) shell, blue for the + inner (ray end) shell — live from the current distance, smoothing and fit + (the fit builds a BVH of the source once per capture), so the settings can be + tuned before re-baking; a double-sided sheet shows both of its shells. Each + shell's offset stops short of self-intersection (cast against the surface + itself), so a tight concavity shows a pinch instead of folding through to the + far side. + For the *Bounding box* and *Hull* shapes the cage is replaced by **proxy + projection**: every texel casts straight along its face normal from just + outside the proxy, as deep as **Proxy search depth** allows (a fraction of + the capture diagonal, 0.1 by default), and takes the first source surface it + meets, so each proxy face shows what sits behind it; a ray that meets + nothing falls back to the nearest surface within the same reach, so a hull's + rounded corner still picks the wall beside it. Keep the depth short: a deep + look sees across courtyards through the block and costs a whole BVH + traversal per empty texel. Texels that find no geometry within reach (the + empty corners of a box around an L-shaped building) are written + with **alpha 0** in the color map and filled from the nearest hit texel, and + the stage status counts them — a shader that clips on alpha turns the proxy + into a silhouette-correct impostor. **Vertex color tints albedo** multiplies + the baked albedo by the source's interpolated vertex color (RGB, read as + linear, the way vertex-tinting shaders do), independently of the vertex color + transfer toggles. It is on by default and does nothing on meshes without + vertex colors; turn it off for shaders that ignore the vertex color. + **Bake mode** selects what lands in the maps: *Materials* transfers the source + maps; *Beauty* bakes the object as the player sees it — realtime/mixed light + with hard ray shadows, the renderer's lightmaps (sampled at its UV2, RGBM/HDR + decoded), ambient (flat, trilight or the ambient probe) and reflection probes + (equirectangular readbacks, roughness-lerped) all folded into one lit + **BaseColor** texture. Baked-only lights are skipped for lightmapped faces, so + nothing is counted twice; unlightmapped faces get albedo × (direct + ambient) + + emission, lightmapped faces get albedo × (lightmap + realtime direct) + + emission — Unity lightmaps store irradiance, which the Lit shader multiplies + by the material's albedo at runtime, so the bake does the same. Specular is + view-dependent and is baked for the scene view camera's position at bake time. + Scene lighting is captured once in world space and every capture space (the + weld, or each keep-hierarchy node) converts into it, so light ranges, shadow + distances and probe bounds stay in world units whatever the source's scale. + Shadow rays test the source's own geometry and the rest of the scene's shadow + casters (enabled, LOD0, casting shadows — captured geometry-only, nearest + renderers first up to a 4M-triangle budget), so a neighbouring building, a + canopy or a sibling keep-hierarchy node shadows the source as in the game; + the Console lists what the snapshot held. Lights honour their culling mask + per captured renderer layer: a light that does not reach the object's layer + neither lights nor shadows it. Reflection probes are blended the way the + runtime does — weight 1 inside a probe's box, falling to 0 across its blend + distance, the two highest-importance probes sharing the sample and the + environment reflection (the custom cubemap, or the reflection Unity + generated from the skybox) taking the rest. + Lightmaps are read only for a Beauty bake, right before it runs, and only + the region each renderer occupies (its lightmap scale/offset rect, one texel + of padding); a region above 2048² texels is downsampled to fit, and the + float readbacks are released when the bake ends — the capture itself keeps + just the texture references, so Materials bakes never touch the lightmaps. + The saved material becomes the pipeline's unlit shader (**Unlit/Texture** in + Built-in, **Universal Render Pipeline/Unlit** in URP) with that one map (the other maps + still export alongside); the 3D preview renders beauty results unlit, exactly + like the saved material. + Bakery setups work through the same path: its HDR colour map decodes directly + (8-bit plain output is auto-detected by its pinned alpha and skips Unity's RGBM + decode), the separate direction texture is not needed (the colour already is + that surface's irradiance), and shadowmask lights contribute through their + realtime component with the bake's own ray shadows standing in for the mask. + In directional mode the bake reproduces the game's exact lightmap response — + URP's `SampleDirectionalLightmap`: the encoded dominant direction is dotted + with the surface's world normal as a half-Lambert and divided by the texel's + rebalancing coefficient — not a flat colour, so oblique surfaces shade the way + they do in play. Reflections follow the game's specular path too — URP's + box-projected probe direction, the prefiltered probe mips selected by the + r(1.7−0.7r)·maxMip remap, and `EnvironmentBRDFSpecular` (surface reduction, + grazing term, Schlick Fresnel) — and all lighting, lightmap response and + specular alike, reacts to the source's normal map, as it does in play. + With the **RemeshDiag** log filter enabled, every bake also prints its health + counters to the Console: welded cage positions and split copies, cage sides + and double-sided positions, vertices whose normal sits >30° off their cage + (max deviation), the longest fitted reach as a multiple of the projection + distance, nearest-fallback projection samples, + front-face filter state, two-sided source face count and the normal map's tilt statistics (mean/max angle + from flat, texels >45°); a map dominated by extreme tilts additionally raises + a warning. +6. **Save**. The output lands in a `remesh` subfolder next to the source model's + asset (its FBX/prefab, or any mesh asset under it), created if missing; + scene-only sources fall back to a folder picker. A unique output folder + inside it contains the model, material, prefab and + BaseColor/Normal/MetallicSmoothness/Occlusion PNGs plus a linear + floating-point Emission EXR. Source files are never overwritten. + *Normalize size (saved at scale 1)* (default on) bakes the source root's + world scale into the saved geometry — real size, identity transform — with + normals and tangents taking the inverse scale and the winding flipping on a + mirroring determinant; off keeps root-local geometry and carries the scale + on the saved transform instead. The RemeshDiag bake summary also reports + the source/target bounds-diagonal ratio and warns when it drifts from 1. + - With **com.unity.formats.fbx** installed the model is exported as a binary + **FBX** (mesh `Name_LOD0` with the generated normals — including UV-island + hard edges — UV0, tangents and transferred vertex colors), a prefab that + instantiates the FBX with the curated material assigned, and the `.mat` next + to it. The FBX importer is set to not generate its own material, so the + folder stays curated. *Embed textures in FBX* (default on) embeds the baked + maps into the binary FBX, making it self-contained and portable; turned off, + the FBX links the exported maps by absolute path on the exporting machine. + Mind the size: the float EXR emission map alone adds 16 bytes per texel. + - Without the FBX package the mesh is saved as a Unity `.asset` plus the same + material, prefab and maps. + +Native work (remesh, simplify, unwrap) and CPU projection run off the main thread. +Texture snapshots and Unity mesh/asset APIs stay on the main thread. + +## Code layout + +- `Editor/Tools/RemeshBakeTool.cs` — the tab: source selection, per-stage + settings and buttons, the right-sidebar previews, the save button. +- `Editor/RemeshPipeline.cs` — the stage machine. The work unit is a node + (capture → voxel → simplified → unwrapped → baked); the weld is one node in + root space, keep-hierarchy one node per renderer, and every stage runs the same + loop. Owns every mesh and preview texture it creates; `ClearFrom(stage)` drops + a stage and everything after it; `Key`/`IsStale` drive the "(settings + changed)" markers. +- `Editor/RemeshSource.cs` — `CollectRenderers` (the one filter for both lanes), + `Capture` (geometry, materials, textures and lightmap references in a given + space; `ReadLightmaps`/`ReleaseLightmaps` around a Beauty bake). +- `Editor/RemeshNative.cs` — the P/Invoke bridge and the post-unwrap normal / + tangent regeneration. +- `Editor/RemeshBaker.cs` — the CPU projection; `Editor/RemeshBeauty.cs` — the + scene lighting snapshot for Beauty bakes. +- `Editor/RemeshExporter.cs` — the save: maps, materials, meshes, prefab or FBX, + one path for the weld and for hierarchy nodes. +- `Editor/RemeshPreview.cs` — the 3D / UV / Maps previews. +- `Editor/Geometry/MeshGeometry.cs` — the shared geometry routines every + projecting or baking tool uses (face normals, bit-exact position welding, + Fibonacci sample directions, 2D barycentrics, point–box distance); + `Editor/Geometry/GpuReadback.cs` — the one GPU → CPU texture readback (blit + through an optional material or sub-rectangle into a linear temporary, then + ReadPixels). Spatial queries are `Editor/TriangleBvh.cs` (3D: binned-SAH build, + watertight two-sided ray test, nearest point, normal- and facing-filtered, + either-side masks) and `Editor/TriangleBvh2D.cs` + (UV space); `Editor/Geometry/GpuBvh.cs` is the same tree on the GPU with the + same queries in batches (`Shaders/BvhQueries.compute`), and + `Shaders/BvhTraversal.hlsl` is the one traversal every compute kernel includes + (the vertex-AO kernel binds the tree through `GpuBvh.Bind`). No tool carries + its own copy of any of these. + +## Geometry implementation + +`Native~/src/remesh.cpp` uses meshoptimizer v1.3 +(`9e1f07b159d3cb777f1c67ed31fc11fd117986f4`, 2026-09-25), pinned by full commit SHA. +Staged exports (ABI 3) run voxel remesh + position weld (`meshLabVoxelRemesh`), +simplifyWithUpdate with the selected regularize/fold/prune options plus degenerate +cleanup (`meshLabSimplify`), and crease-aware normal generation + xatlas unwrap +with explicit chart/pack options (`meshLabUnwrap`, which also returns each +vertex's island). The original one-shot `meshLabRemeshBuild` remains for the +native tests. The unwrap vertex layout is sixteen float32 values — position, +normal, UV0, tangent (xyz direction plus ±1 handedness) — with the tangent +regenerated by `meshopt_generateTangents` (MikkT-compatible) from the final +atlas layout, so the bake, the preview and the saved mesh all encode against +one basis; corners that disagree duplicate their vertex. +The vertex normals themselves are regenerated in C# **after** the UV cut from +the split geometry. In the UV-island modes every output vertex is its own +normal group: crease splits and chart borders are already vertex splits and no +face crosses one, so the accumulation smooths inside every island and stays +hard across creases and island borders alike. In *Smooth* and *Angle* the +copies xatlas duplicated along chart borders are grouped back together by +position and native (pre-chart, crease-split) normal, so islands stay smooth +and only the crease edges harden; normal smoothing runs over the same groups. +The *Normal weighting* option selects the accumulation weight — face area +(meshopt's own), corner angle, or both multiplied (the Blender Weighted +Normal modifier analog). Tangents are re-orthogonalized against the final +normals so the saved frame matches the one the bake encodes against. +v1.3 removed the non-functional Thicken flag and renumbered `meshopt_RemeshShell` +and `meshopt_RemeshSolve`. The bridge keeps its own flag bits (1 = fit source +surface, 2 = two-sided shell) and maps them by name, so the C# ABI is unchanged; +the native test pins that mapping. + +Each job owns its own xatlas instance and result handle, independently of the +legacy global repack bridge. C# always destroys the handle, including cancellation +and failure. Native exports use a version probe and capacity-checked copies. +The intermediate mesh is limited to five million triangles. + +Native binaries must be rebuilt by **Build Native Libraries**. Its push job +publishes Windows x64, Linux x64 and universal macOS binaries into the branch; +its PR jobs compile/test without publishing. An old binary fails the capability +probe with an actionable message instead of invoking a missing entry point. +Existing bridge signatures and existing UV2 transfer behavior are unchanged. + +## Material transfer + +For every covered destination texel, the CPU traces from the destination surface +plus its normal offset back toward the source. A miss falls back to a bounded +nearest-point query. Barycentric source UV0 selects the original submesh material. +Normal maps are decoded on the GPU before readback and transformed from source +TBN to destination TBN. Metallic/smoothness, occlusion and emission stay separate +from base color. The output normal map is imported as a Unity normal map. + +Material textures are read at their imported dimensions, without the web demo's +1K rescaling. The readback cache has a 512 MiB limit (HDR emission costs 16 bytes +per pixel). Source color maps retain sRGB byte precision and are interpolated in +linear space; normal/data maps use linear bytes. Emission readback/export uses +floating point. This does not recover detail already lost to source import +compression or max-size settings. + +## Boundaries and current limitations + +- Experimental triangle remeshing, not animation-ready quad retopology. Thin + sheets, tiny gaps and adjacent disconnected parts can collapse or merge. +- **Keep hierarchy** (`RemeshSettings.keepHierarchy`) splits the capture at the + renderer level: every node the weld would have folded in runs the whole + voxel → simplify → unwrap → bake chain in its own local space, and the save + rebuilds them as children of one root prefab at their captured root-relative + transforms, each with its own baked material and mesh asset. The FBX lane + stays weld-only (a multi-node FBX round-trip re-imports per-node normals and + mappings for no gain over the native prefab), the source root's scale stays on + the prefab root (so *Normalized size* is weld-only and says so), and preview + panels show the largest node's stage outputs. Node granularity = the renderers + of the capture scope (the same list the weld captures); each renderer is its + own node, so nothing is captured twice. A node's capture space is the TRS the + save restores, so a sheared renderer matrix (rotated child under a + non-uniform parent) folds its residual into the captured geometry. +- No skinning, blend-shape, alpha cutout/transparency, parallax or detail-layer + transfer. Shaders other than Standard and URP/Lit bake base color, normal, + occlusion, emission and scalar metallic/smoothness from common property names; + every such downgrade is logged as a warning. Result materials target Built-in + and URP; HDRP export is not implemented. +- CPU projection is slower than a dedicated GPU baker, particularly at 4K. + Source snapshot/readback and export are synchronous editor operations. +- Cancellation is observed after the current native remesh/unwrap completes, + and throughout CPU projection. Assembly reload is deferred while the job owns + native resources. Switching tabs requests cancellation and disposes previews. +- UV0 is newly generated; this feature does not generate/transfer lightmap UV2. +- Output color/normal/material maps use 8-bit PNG; emission is float EXR. +- Save creates assets, not a scene replacement. Asset export is not scene Undo; + use the generated folder as the unit of deletion. A failed export rolls it back. +- Unity visual QA remains required before release: native and static checks alone + do not establish shader, material or imported-normal-map correctness. + +## Validation + +Native tests require no Unity license: + +```sh +cmake -S Native~ -B build -DCMAKE_BUILD_TYPE=Release -DMESHLAB_BUILD_TESTS=ON +cmake --build build --config Release +ctest --test-dir build -C Release --output-on-failure +``` + +The native test runs the complete cube → remesh → simplify → normal → UV pipeline, +checks finite data, normalized normals/UVs and valid indices, verifies the target +budget under relaxed error, repeated owned-handle cleanup and invalid input/copy +capacity rejection. It also runs every solve/shell flag combination and requires +the two-sided shell of the closed cube to be larger than the solid remesh, so a +flag that stops reaching meshoptimizer fails the build. The staged exports are +tested separately: voxel output copy and capacity guard, error-limited +simplification collapsing the flat cube faces far below the voxel density, +rejection of unknown flags and malformed unwrap options, and an unwrap whose +indices, island ids and UVs are all in range. CI runs it on all three native +platforms. + +Unity Test Runner: `RemeshBakeTests` covers UV raster barycentrics, separate material +channels/HDR emission, source-normal to destination-tangent projection, image +sampling/wrapping, coincident-surface projection and cancellation, multisampled +coverage of a chart thinner than a texel, independent vertex color/alpha transfer, +UV-island hard edges and source-root selection following. Run these in +Unity 6000.0+ after the native binaries are updated. The repository's Unity CI is +license-gated; a skipped job is not a passed compilation/test run. + +Manual gate: textured multi-submesh prop, nested transforms including negative +scale, normal-mapped high-poly with bevels, thin sheet, LODGroup, 4K source texture, +missed projection, cancel/tab switch, each hard-edge mode, every preview view, a +Read/Write-disabled FBX, a non-Standard shader, and export/reimport in Built-in and URP. +Compare actual rendered output against the source and confirm the source assets +remain byte-for-byte unchanged. diff --git a/Documentation~/TRANSFER_BENCHMARK.md b/Documentation~/TRANSFER_BENCHMARK.md index 3b21d711..355f2480 100644 --- a/Documentation~/TRANSFER_BENCHMARK.md +++ b/Documentation~/TRANSFER_BENCHMARK.md @@ -66,12 +66,14 @@ 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. 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. | +| `UvtLog.Category` | `Editor/UvtLog.cs` | Per-subsystem log filter. Toggle in *Diagnostics → Log filters* (`DebugUi.LogFilters`). | +| `BenchmarkRecorder` | `Editor/Bench/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. | -| `TestSuiteAsset` | `Editor/Settings/TestSuiteAsset.cs` | ScriptableObject registry of benchmark cases (FBX + LOD path + expected ranges). Create via `Assets → Create → Mesh Lab → Sweep Test Suite`. | +| `SweepRunner` / `BenchmarkRunner` | `Editor/Bench/SweepRunner.cs`, `Editor/Bench/BenchmarkRunner.cs` | The sweep and the multi-model benchmark as libraries: matrix → cells, per-cell pipeline runs through an `ISweepHost` / `IBenchmarkHost` (the tab implements them), artefact routing, aggregate, manifest, archive. Any tool with an end-to-end pipeline can host them. | +| `DebugUi` / `BakeHealth` / `HierarchicalDiag` / `FbxMetricsExporter` | `Editor/Diagnostics/` | The Show Debug UI gate, banner and log-filter block; the Remesh & Bake health report; the hierarchical probe; the FBX characterization export. The **Diagnostics** tab (`Editor/Tools/DiagnosticsTool.cs`, shown with Show Debug UI on) hosts the sweep / benchmark buttons, the log filters and the report exports. | +| `TestSuiteAsset` | `Editor/Bench/TestSuiteAsset.cs` | ScriptableObject registry of benchmark cases (FBX + LOD path + expected ranges). Create via `Assets → Create → Mesh Lab → Sweep Test Suite`. | ## Metrics (one CSV row per mesh × LOD) @@ -181,7 +183,7 @@ sort is free. For cross-model regression coverage — running the full sweep matrix on every case in the suite without manually switching FBXes — use **Run -Multi-Case (N × M)**. Located in *Setup → Parameter Sweep*, right of the +Multi-Case (N × M)**. Located in the *Diagnostics* tab, right of the single-model **Run Sweep** button. `N` is the number of `cases`, `M` is the cell count. @@ -237,7 +239,7 @@ resetBetweenRuns = true Per-cell label encodes every axis so the recovery regex can reconstruct CellConfigs from filenames: `sweep_res{R}_pad{S}_bdr{B}_arap{A}_stretch{T}_os{O}_sym{legacy|adaptive}`. -In *LightmapTransferTool → Setup tab*, assign the asset to the **Sweep suite** +In the *Diagnostics* tab (Show Debug UI on), assign the asset to the **Sweep suite** field; the neighbouring **Run Sweep (N)** button iterates the cartesian product (N = product of array lengths). Each cell: diff --git a/Editor/Assets.meta b/Editor/Assets.meta new file mode 100644 index 00000000..404251e3 --- /dev/null +++ b/Editor/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d3603a9b235147d2b0cdfbada6f91406 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Assets/FbxExport.cs b/Editor/Assets/FbxExport.cs new file mode 100644 index 00000000..5f7775cf --- /dev/null +++ b/Editor/Assets/FbxExport.cs @@ -0,0 +1,1359 @@ +// FbxExport.cs — the mechanics of re-saving an FBX from Mesh Lab: the export mesh for +// an entry (result geometry, the source's UV channels, the AO component, matched +// tangents), the isolated channel re-save (snapshot the intended channels, clone the +// FBX prefab, write only those, atomic file replace), the LOD-rebuild hierarchy pieces +// (mesh replacement by name, new LOD children, stale-child pruning, hierarchy +// normalisation, collision injection and stripping, material trims), the write itself +// and the post-reimport scene relink. Tools keep the policy — dialogs, backups, +// importer locks, which entries, what to refresh — and call these. +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using Object = UnityEngine.Object; + +namespace SashaRX.UnityMeshLab +{ + internal static class FbxExport + { + const string MetaExtension = ".meta"; + const string RelinkUndoLabel = "Relink Mesh"; + + // ───────────────────────────────────────────────────────────────── + // Names + // ───────────────────────────────────────────────────────────────── + + /// + /// The FBX node name an entry exports under: the FBX sub-asset's name, else the + /// working mesh's, else the result's, else the renderer's; transient preview names + /// ("Hidden/…") fall through to the renderer. "Mesh" when nothing is named. + /// + internal static string ResolveExportMeshName(MeshEntry entry, Mesh resultMesh) + { + if (entry?.fbxMesh != null && !string.IsNullOrEmpty(entry.fbxMesh.name)) + return entry.fbxMesh.name; + + string fallback = entry?.originalMesh != null ? entry.originalMesh.name : null; + if (string.IsNullOrEmpty(fallback) && resultMesh != null) + fallback = resultMesh.name; + + if (!string.IsNullOrEmpty(fallback) && + (fallback.StartsWith("Hidden/", StringComparison.OrdinalIgnoreCase) || + fallback.StartsWith("Hidden_", StringComparison.OrdinalIgnoreCase)) && + entry?.renderer != null && !string.IsNullOrEmpty(entry.renderer.name)) + { + return entry.renderer.name; + } + + if (!string.IsNullOrEmpty(fallback)) return fallback; + if (entry?.renderer != null && !string.IsNullOrEmpty(entry.renderer.name)) return entry.renderer.name; + return "Mesh"; + } + + /// + /// A mesh name a DCC round-trip left behind (`Scene`, `Geometry`, `Default`, `Mesh`, + /// `Combined Mesh…`) or no name at all. + /// + internal static bool IsGenericMeshName(string name) + { + if (string.IsNullOrEmpty(name)) return true; + switch (name) + { + case "Scene": + case "Geometry": + case "Default": + case "Mesh": + case "Combined Mesh": + return true; + default: + return name.StartsWith("Combined Mesh", StringComparison.Ordinal); + } + } + + /// A material name an importer invents (`Lit`, `Default`, `No Name`…) or no name at all. + internal static bool IsPlaceholderMaterialName(string name) + { + if (string.IsNullOrEmpty(name)) return true; + switch (name) + { + case "Lit": + case "Default": + case "Material": + case "DefaultMaterial": + case "Default-Material": + case "No Name": + return true; + default: + return false; + } + } + + // ───────────────────────────────────────────────────────────────── + // UV channels + // ───────────────────────────────────────────────────────────────── + + /// + /// Copies the UV channels lacks from + /// (same vertex count required). A 2D channel that is + /// all zeros is not worth carrying; existing channels are never overwritten. + /// + internal static void PreserveUvChannels(Mesh exportMesh, Mesh sourceMesh) + { + if (exportMesh == null || sourceMesh == null) return; + if (sourceMesh.vertexCount != exportMesh.vertexCount) return; + for (int ch = 0; ch < 8; ch++) + { + var attr = (VertexAttribute)((int)VertexAttribute.TexCoord0 + ch); + if (exportMesh.HasVertexAttribute(attr)) continue; + if (!sourceMesh.HasVertexAttribute(attr)) continue; + + int dim = sourceMesh.GetVertexAttributeDimension(attr); + if (dim <= 2) + { + var uv = new List(); + sourceMesh.GetUVs(ch, uv); + if (uv.Count == 0) continue; + bool allZero = true; + for (int i = 0; i < uv.Count; i++) + if (uv[i].x != 0f || uv[i].y != 0f) { allZero = false; break; } + if (allZero) continue; + exportMesh.SetUVs(ch, uv); + } + else if (dim == 3) + { + var uv = new List(); + sourceMesh.GetUVs(ch, uv); + if (uv.Count > 0) exportMesh.SetUVs(ch, uv); + } + else + { + var uv = new List(); + sourceMesh.GetUVs(ch, uv); + if (uv.Count > 0) exportMesh.SetUVs(ch, uv); + } + } + } + + /// Replaces UV channel of with the source's, at the source's width. + internal static void OverwriteUvChannel(Mesh exportMesh, Mesh sourceMesh, int channel) + { + if (exportMesh == null || sourceMesh == null) return; + if (channel < 0 || channel > 7) return; + if (sourceMesh.vertexCount != exportMesh.vertexCount) return; + var attr = (VertexAttribute)((int)VertexAttribute.TexCoord0 + channel); + if (!sourceMesh.HasVertexAttribute(attr)) return; + + int dim = sourceMesh.GetVertexAttributeDimension(attr); + if (dim <= 2) + { + var uv = new List(); + sourceMesh.GetUVs(channel, uv); + if (uv.Count == exportMesh.vertexCount) exportMesh.SetUVs(channel, uv); + } + else if (dim == 3) + { + var uv = new List(); + sourceMesh.GetUVs(channel, uv); + if (uv.Count == exportMesh.vertexCount) exportMesh.SetUVs(channel, uv); + } + else + { + var uv = new List(); + sourceMesh.GetUVs(channel, uv); + if (uv.Count == exportMesh.vertexCount) exportMesh.SetUVs(channel, uv); + } + } + + /// + /// Writes one component (0 = X, 1 = Y) of the donor's UV channel into the export + /// mesh's, keeping the other component. A missing export channel starts as the donor's. + /// + internal static void MergeUvComponentFromDonor(Mesh exportMesh, Mesh donorMesh, int uvChannel, int uvComponent) + { + if (exportMesh == null || donorMesh == null) return; + if (exportMesh.vertexCount != donorMesh.vertexCount) return; + if (uvChannel < 0 || uvChannel > 7) return; + if (uvComponent < 0 || uvComponent > 1) return; + + var donorUv = new List(); + donorMesh.GetUVs(uvChannel, donorUv); + if (donorUv.Count != exportMesh.vertexCount) return; + + var exportUv = new List(); + exportMesh.GetUVs(uvChannel, exportUv); + if (exportUv.Count != exportMesh.vertexCount) + exportUv = new List(donorUv); + + for (int i = 0; i < exportUv.Count; i++) + { + var src = donorUv[i]; + var dst = exportUv[i]; + exportUv[i] = uvComponent == 0 ? new Vector2(src.x, dst.y) : new Vector2(dst.x, src.y); + } + exportMesh.SetUVs(uvChannel, exportUv); + } + + /// True when the mesh carries UV channel for every vertex. + internal static bool HasUvChannelData(Mesh mesh, int channel) + { + if (mesh == null || channel < 0 || channel > 7) return false; + var attr = (VertexAttribute)((int)VertexAttribute.TexCoord0 + channel); + if (!mesh.HasVertexAttribute(attr)) return false; + + int dim = mesh.GetVertexAttributeDimension(attr); + int vCount = mesh.vertexCount; + if (dim <= 2) + { + var uv = new List(); + mesh.GetUVs(channel, uv); + return uv.Count == vCount; + } + if (dim == 3) + { + var uv = new List(); + mesh.GetUVs(channel, uv); + return uv.Count == vCount; + } + var uv4 = new List(); + mesh.GetUVs(channel, uv4); + return uv4.Count == vCount; + } + + /// + /// The mesh of an entry that carries UV channel : AO + /// is written on the working or FBX mesh, so those come first and the transfer + /// result last (its UV1 stays authoritative while AO comes from the donor). + /// + internal static Mesh SelectUvDonor(MeshEntry entry, Mesh resultMesh, int uvChannel) + { + var candidates = new[] { entry?.originalMesh, entry?.fbxMesh, entry?.repackedMesh, entry?.transferredMesh, resultMesh }; + for (int i = 0; i < candidates.Length; i++) + if (HasUvChannelData(candidates[i], uvChannel)) return candidates[i]; + return null; + } + + // ───────────────────────────────────────────────────────────────── + // The export mesh of one entry + // ───────────────────────────────────────────────────────────────── + + /// + /// The temporary mesh written for an entry: a copy of + /// named , with the UV channels of the FBX mesh + /// and then the working mesh carried over, UV1 taken from the working mesh when the + /// entry has no repack or transfer result, the AO component merged from the best + /// donor, and tangents matched to the FBX import. Added to ; + /// the caller destroys it after the write. + /// + internal static Mesh BuildExportMesh(MeshEntry entry, Mesh resultMesh, SidecarStore.AoUvTarget ao, List tempSink) + { + if (tempSink == null) throw new ArgumentNullException(nameof(tempSink)); + var exportMesh = Object.Instantiate(resultMesh); + tempSink.Add(exportMesh); + // Without an explicit name, Object.Instantiate produces "X(Clone)" and Unity's + // FBX Exporter falls back to the FBX scene name ("Scene") when writing the + // FbxMesh node — every reimported mesh ends up named "Scene". Pin the canonical + // name now so the FBX node and post-reimport mesh asset stay aligned. + exportMesh.name = ResolveExportMeshName(entry, resultMesh); + + // UV channels from fbxMesh first (base UVs), then from originalMesh (has AO + // and other tool modifications). + if (entry.fbxMesh != null) + PreserveUvChannels(exportMesh, entry.fbxMesh); + if (entry.originalMesh != null && entry.originalMesh != entry.fbxMesh) + { + PreserveUvChannels(exportMesh, entry.originalMesh); + // Only overwrite UV1 from originalMesh when there is no repack/transfer + // result — otherwise the repacked lightmap UV in channel 1 takes priority. + if (entry.repackedMesh == null && entry.transferredMesh == null) + OverwriteUvChannel(exportMesh, entry.originalMesh, 1); + } + // AO often lives in a UV component. Source meshes may not have that channel + // at all, so pick the best available donor. + if (ao.IsSet) + { + var donor = SelectUvDonor(entry, resultMesh, ao.channel); + if (donor != null) + MergeUvComponentFromDonor(exportMesh, donor, ao.channel, ao.component); + } + TangentValidator.EnforceTangentsMatchOriginal(exportMesh, entry.fbxMesh, "FBX Export"); + return exportMesh; + } + + // ───────────────────────────────────────────────────────────────── + // Which FBX + // ───────────────────────────────────────────────────────────────── + + /// + /// The FBX the entries came from: the first entry's FBX sub-asset path, else the + /// FBX behind the LODGroup's prefab source or one of its renderers, else + /// . Null when nothing resolves. + /// + internal static string ResolveSourceFbxPath(IEnumerable entries, LODGroup lodGroup, string fallback) + { + if (entries != null) + { + foreach (var e in entries) + { + if (e?.fbxMesh == null) continue; + string p = AssetDatabase.GetAssetPath(e.fbxMesh); + if (SidecarStore.IsFbxPath(p)) return p; + } + } + if (lodGroup != null) + { + var prefabSrc = PrefabUtility.GetCorrespondingObjectFromSource(lodGroup.gameObject); + if (prefabSrc != null) + { + string p = AssetDatabase.GetAssetPath(prefabSrc); + if (SidecarStore.IsFbxPath(p)) return p; + } + foreach (var r in lodGroup.GetComponentsInChildren(true)) + { + var rSrc = PrefabUtility.GetCorrespondingObjectFromSource(r); + if (rSrc == null) continue; + string p = AssetDatabase.GetAssetPath(rSrc); + if (SidecarStore.IsFbxPath(p)) return p; + } + } + return string.IsNullOrEmpty(fallback) ? null : fallback; + } + + /// + /// The included entries with a result, grouped by the FBX they export into: the + /// entry's own FBX when it has one, else (generated + /// LODs live in `.asset` files). Entries with neither are left out. + /// + internal static Dictionary> GroupByFbx( + IEnumerable entries, Func resultMeshOf, string sourceFbxPath) + { + var groups = new Dictionary>(); + if (entries == null) return groups; + foreach (var e in entries) + { + if (e == null || !e.include) continue; + Mesh resultMesh = resultMeshOf(e); + if (resultMesh == null) continue; + Mesh pathMesh = e.fbxMesh ?? e.originalMesh; + string fbxPath = pathMesh != null ? AssetDatabase.GetAssetPath(pathMesh) : null; + if (!SidecarStore.IsFbxPath(fbxPath)) fbxPath = sourceFbxPath; + if (string.IsNullOrEmpty(fbxPath)) continue; + if (!groups.TryGetValue(fbxPath, out var list)) + groups[fbxPath] = list = new List<(MeshEntry, Mesh)>(); + list.Add((e, resultMesh)); + } + return groups; + } + + /// The first entry material that is not a preview shader; the collision nodes' material. + internal static Material FirstRealMaterial(IEnumerable entries) + { + if (entries == null) return null; + foreach (var e in entries) + { + var mat = e?.renderer != null ? e.renderer.sharedMaterial : null; + if (mat != null && !CheckerTexturePreview.IsPreviewShader(mat.shader.name)) return mat; + } + return null; + } + + // ───────────────────────────────────────────────────────────────── + // Isolated-channel re-save (per FbxExportIntent) + // + // Re-saves the source FBX overwriting only the per-vertex channels listed in the + // intent. All other data — node names, hierarchy, transforms, material + // assignments, untouched UV channels, vertex colors, normals, tangents — is + // inherited from the source FBX asset on disk via clone-and-snapshot. + // ───────────────────────────────────────────────────────────────── + + internal sealed class Snapshot + { + public int vertexCount; + public Color32[] colors32; + public Color[] colors; + public Vector3[] normals; + public Vector4[] tangents; + public readonly Vector2[][] uvs = new Vector2[8][]; + } + + /// The channels of the intent covers, captured before any reimport. + internal static Snapshot BuildSnapshot(Mesh source, FbxExportIntent intent) + { + var snap = new Snapshot { vertexCount = source.vertexCount }; + if ((intent & FbxExportIntent.VertexColors) != 0) + { + var c32 = source.colors32; + if (c32 != null && c32.Length == source.vertexCount) + snap.colors32 = c32; + else + { + var c = source.colors; + if (c != null && c.Length == source.vertexCount) snap.colors = c; + } + } + if ((intent & FbxExportIntent.Normals) != 0) + { + var n = source.normals; + if (n != null && n.Length == source.vertexCount) snap.normals = n; + } + if ((intent & FbxExportIntent.Tangents) != 0) + { + var t = source.tangents; + if (t != null && t.Length == source.vertexCount) snap.tangents = t; + } + for (int ch = 0; ch < 8; ch++) + { + if (!intent.IncludesUv(ch)) continue; + var list = new List(); + source.GetUVs(ch, list); + if (list.Count == source.vertexCount) snap.uvs[ch] = list.ToArray(); + } + return snap; + } + + /// + /// Writes the snapshots onto fresh copies of the clone's meshes (matched by + /// sub-asset name; the live FBX sub-assets are never mutated). Every copy goes to + /// . Returns the number of channel updates. + /// + internal static int CopySnapshotsToClone(GameObject tempRoot, Dictionary snapshots, List tempSink) + { + if (tempSink == null) throw new ArgumentNullException(nameof(tempSink)); + if (snapshots == null) return 0; + int updated = 0, visited = 0, matched = 0; + foreach (var cloneMf in tempRoot.GetComponentsInChildren(true)) + { + if (cloneMf == null || cloneMf.sharedMesh == null) continue; + visited++; + if (!snapshots.TryGetValue(cloneMf.sharedMesh.name, out var snap)) continue; + matched++; + + if (snap.vertexCount != cloneMf.sharedMesh.vertexCount) + { + UvtLog.Warn($"[FBX Export] Skip '{cloneMf.sharedMesh.name}': vertex-count mismatch " + + $"(authored={snap.vertexCount}, FBX clone={cloneMf.sharedMesh.vertexCount}). " + + "The source FBX re-imports at a different vertex count than the tool worked on — " + + "usually 'Generate Lightmap UVs' splitting vertices. Disable it on the model " + + "importer and re-run the tool."); + continue; + } + + var cloneMesh = Object.Instantiate(cloneMf.sharedMesh); + cloneMesh.name = cloneMf.sharedMesh.name; + tempSink.Add(cloneMesh); + + if (snap.colors32 != null) { cloneMesh.colors32 = snap.colors32; updated++; } + else if (snap.colors != null) { cloneMesh.colors = snap.colors; updated++; } + if (snap.normals != null) { cloneMesh.normals = snap.normals; updated++; } + if (snap.tangents != null) { cloneMesh.tangents = snap.tangents; updated++; } + for (int ch = 0; ch < 8; ch++) + { + if (snap.uvs[ch] == null || snap.uvs[ch].Length != cloneMesh.vertexCount) continue; + cloneMesh.SetUVs(ch, snap.uvs[ch]); + updated++; + } + cloneMf.sharedMesh = cloneMesh; + } + UvtLog.Verbose($"[FBX Export] CopySnapshotsToClone: visited={visited}, matched={matched}, updates={updated}."); + return updated; + } + + /// + /// Puts a source ModelImporter back to the state the export found it in; a no-op + /// when nothing was changed. Created before the importer is touched and told about + /// each change before the reimport that applies it, so a throwing reimport is + /// covered too. Disposed at the export's exit whichever way it leaves. + /// + internal sealed class ImporterRestoreScope : IDisposable + { + readonly string path; + ModelImporter importer; + bool readable, quads; + + public ImporterRestoreScope(string path) { this.path = path; } + + /// isReadable was flipped on for the export; put it back off. + public void RestoreReadable(ModelImporter source) { importer = source; readable = true; } + /// keepQuads was toggled on for the export; put it back off. + public void RestoreQuads(ModelImporter source) { importer = source; quads = true; } + + public void Dispose() + { + if (importer == null || (!readable && !quads)) return; + if (readable) importer.isReadable = false; + if (quads) importer.keepQuads = false; + Uv2AssetPostprocessor.bypassPaths.Add(path); + importer.SaveAndReimport(); + } + } + + /// + /// Soft pre-export checks on the cloned hierarchy against the FBX pipeline + /// checklist: generic or invalid node and mesh names, placeholder materials, vertex + /// colours outside [0,1] (when the intent writes them), negative-determinant + /// scales. Every finding is a warning; none blocks the export. + /// + internal static void RunPreflight(GameObject tempRoot, FbxExportIntent intent, Dictionary snapshots) + { + if (tempRoot == null) return; + + int badNodeNames = 0, badMeshNames = 0; + foreach (var t in tempRoot.GetComponentsInChildren(true)) + if (string.IsNullOrEmpty(t.name) || MeshHygieneUtility.HasInvalidChars(t.name)) badNodeNames++; + foreach (var mf in tempRoot.GetComponentsInChildren(true)) + if (mf.sharedMesh != null && IsGenericMeshName(mf.sharedMesh.name)) badMeshNames++; + foreach (var smr in tempRoot.GetComponentsInChildren(true)) + if (smr.sharedMesh != null && IsGenericMeshName(smr.sharedMesh.name)) badMeshNames++; + if (badNodeNames > 0) + UvtLog.Warn($"[FBX Preflight] {badNodeNames} node name(s) are empty or contain invalid characters (see §5.5/§8)."); + if (badMeshNames > 0) + UvtLog.Warn($"[FBX Preflight] {badMeshNames} mesh(es) have a generic name (Scene/Geometry/Default/empty); see §5.5."); + + int placeholderMats = 0; + foreach (var mr in tempRoot.GetComponentsInChildren(true)) + { + var mats = mr.sharedMaterials; + if (mats == null) continue; + foreach (var mat in mats) + if (mat == null || IsPlaceholderMaterialName(mat.name)) placeholderMats++; + } + if (placeholderMats > 0) + UvtLog.Warn($"[FBX Preflight] {placeholderMats} placeholder material slot(s) (Lit/Default/null); see §1.5."); + + if ((intent & FbxExportIntent.VertexColors) != 0 && snapshots != null) + { + int outOfRangeMeshes = 0; + foreach (var snap in snapshots.Values) + { + var c = snap.colors; + if (c == null) continue; // colors32 is byte-clamped by definition + for (int i = 0; i < c.Length; i++) + { + var v = c[i]; + if (v.r < 0f || v.r > 1f || v.g < 0f || v.g > 1f || v.b < 0f || v.b > 1f || v.a < 0f || v.a > 1f) + { outOfRangeMeshes++; break; } + } + } + if (outOfRangeMeshes > 0) + UvtLog.Warn($"[FBX Preflight] {outOfRangeMeshes} mesh(es) have vertex colors outside [0,1]; see §4.2."); + } + + int negScaleNodes = 0; + foreach (var t in tempRoot.GetComponentsInChildren(true)) + { + var s = t.lossyScale; + if (s.x * s.y * s.z < 0f) negScaleNodes++; + } + if (negScaleNodes > 0) + UvtLog.Warn($"[FBX Preflight] {negScaleNodes} node(s) have negative-determinant accumulated scale (mesh will render transparent from front); see §7.8."); + } + + /// + /// Re-saves the FBX at (or writes a new file at + /// ) overwriting only the channels in + /// ; everything else comes from the source FBX on disk. + /// Phases: importer prep (one reimport, scoped to the intent; the source importer + /// ends as it started except for the deliberate generateSecondaryUV/keepQuads + /// locks), clone and overwrite, atomic write, reimport and relink of + /// 's references (source re-save only). The caller + /// restores its previews before and its working meshes after. Returns true when + /// the file was written. + /// + /// The material `_COL` renderers get when the intent includes Materials; null destroys their renderers. + internal static bool WriteChannels( + string sourceFbxPath, + IEnumerable entries, + FbxExportIntent intent, + string outputFbxPath, + Material collisionMaterial, + LODGroup sceneRoot) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + if (string.IsNullOrEmpty(sourceFbxPath) || entries == null) return false; + + string targetFbxPath = string.IsNullOrEmpty(outputFbxPath) ? sourceFbxPath : outputFbxPath; + bool isVariantExport = !string.IsNullOrEmpty(outputFbxPath) + && !string.Equals(outputFbxPath, sourceFbxPath, StringComparison.OrdinalIgnoreCase); + + // Snapshot pre-export. Phase 1 can trigger a reimport that resets the shared + // FBX sub-asset buffers in place — keying by sub-asset name lets us find the + // original data after the reimport. + var snapshots = new Dictionary(StringComparer.Ordinal); + foreach (var e in entries) + { + if (e == null || !e.include) continue; + Mesh sm = e.originalMesh ?? e.fbxMesh; + if (sm == null || string.IsNullOrEmpty(sm.name)) continue; + snapshots[sm.name] = BuildSnapshot(sm, intent); + } + if (snapshots.Count == 0) + { + UvtLog.Warn($"[FBX Export] No source meshes had data for intent {intent}."); + return false; + } + + // ── Phase 1: importer ── + using var importerRestore = new ImporterRestoreScope(sourceFbxPath); + var srcImporter = AssetImporter.GetAtPath(sourceFbxPath) as ModelImporter; + if (!isVariantExport) + { + if (srcImporter != null) + { + bool needsReimport = false; + // generateSecondaryUV writes Unity UV channel 1. Lock it only when the + // intent overwrites that channel, and leave it off: re-enabling it would + // regenerate channel 1 on the restore reimport over the authored UV1. + if (intent.IncludesUv(1) && srcImporter.generateSecondaryUV) + { srcImporter.generateSecondaryUV = false; needsReimport = true; } + // weldVertices / meshCompression / meshOptimizationFlags are deliberately + // left alone: the snapshot and the clone both come from the current + // import and share its vertex layout; a reimport with different settings + // would renumber the clone and every mesh would be skipped. + if (!srcImporter.isReadable) + { srcImporter.isReadable = true; needsReimport = true; importerRestore.RestoreReadable(srcImporter); } + // keepQuads changes only the index buffer (quads stay quads through + // the exporter); the vertex stream is untouched, so the snapshots still + // match. It persists on purpose: restoring it would triangulate the + // just-written quad FBX on the next import. + if (!srcImporter.keepQuads) + { srcImporter.keepQuads = true; needsReimport = true; } + if (needsReimport) + { + Uv2AssetPostprocessor.bypassPaths.Add(sourceFbxPath); + srcImporter.SaveAndReimport(); + } + } + } + else if (srcImporter != null && !srcImporter.keepQuads && AssetDatabase.LoadMainAssetAtPath(sourceFbxPath) != null) + { + // A variant writes a NEW file and must leave the source importer unchanged, + // but the clone needs the source's quad topology; toggle keepQuads for the + // clone reimport and put it back at exit. + srcImporter.keepQuads = true; + importerRestore.RestoreQuads(srcImporter); + Uv2AssetPostprocessor.bypassPaths.Add(sourceFbxPath); + srcImporter.SaveAndReimport(); + } + + // ── Phase 2: clone and overwrite ── + var fbxAsset = AssetDatabase.LoadMainAssetAtPath(sourceFbxPath) as GameObject; + if (fbxAsset == null) + { + UvtLog.Error($"[FBX Export] Cannot load FBX asset at '{sourceFbxPath}'."); + return false; + } + var tempRoot = Object.Instantiate(fbxAsset); + tempRoot.name = fbxAsset.name; + + bool exported = false; + Dictionary renameMap = null; + var tempMeshes = new List(); + try + { + int updated = CopySnapshotsToClone(tempRoot, snapshots, tempMeshes); + if (updated == 0 && (intent & (FbxExportIntent.Hierarchy | FbxExportIntent.Materials)) == 0) + { + // Hierarchy / Materials intents restructure the FBX without per-vertex + // changes; a per-vertex-only intent with no matching mesh writes nothing. + UvtLog.Warn($"[FBX Export] No matching meshes in clone for intent {intent}."); + return false; + } + if ((intent & FbxExportIntent.Hierarchy) != 0) + renameMap = NormalizeExportHierarchy(tempRoot, tempMeshes); + if ((intent & FbxExportIntent.Materials) != 0) + { + PrepareCollisionMaterials(tempRoot, collisionMaterial); + TrimMaterialArrays(tempRoot); + } + RunPreflight(tempRoot, intent, snapshots); + + // ── Phase 3: write ── + // Signal the UV2 postprocessor to skip sidecar injection on the reimport the + // write triggers — otherwise an isolated UV2 export would be overwritten by + // stale sidecar data at once. + Uv2AssetPostprocessor.fbxOverwritePaths.Add(targetFbxPath); + WriteAtomic(targetFbxPath, tempRoot); + UvtLog.Info($"[FBX Export] Isolated channels {intent} ({updated} updates) -> {targetFbxPath}"); + exported = true; + } + catch (Exception ex) + { + Uv2AssetPostprocessor.fbxOverwritePaths.Remove(targetFbxPath); + UvtLog.Error("[FBX Export] Isolated channel export failed: " + ex); + return false; + } + finally + { + Object.DestroyImmediate(tempRoot); + DestroyTempMeshes(tempMeshes); + } + + // ── Phase 4: reimport and relink ── + AssetDatabase.Refresh(); + if (isVariantExport) + { + // The variant was written with the source's quad topology; pin keepQuads + // on its importer so the project view matches the file. + var outImporter = AssetImporter.GetAtPath(targetFbxPath) as ModelImporter; + if (outImporter != null && !outImporter.keepQuads) + { + outImporter.keepQuads = true; + outImporter.SaveAndReimport(); + } + } + else if (sceneRoot != null) + { + // renameMap is non-null only when the intent included Hierarchy; narrow + // per-vertex intents re-bind purely by sub-asset name. + RelinkSceneMeshReferences(sourceFbxPath, renameMap != null && renameMap.Count > 0 ? renameMap : null, sceneRoot); + } + return exported; +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); + return false; +#endif + } + + // ───────────────────────────────────────────────────────────────── + // Writing + // ───────────────────────────────────────────────────────────────── + + /// + /// Writes to as a binary FBX and + /// throws when the exporter produced nothing. For new files; a re-save of an + /// existing asset goes through . + /// + internal static void Write(string fbxPath, GameObject root, bool embedTextures = false) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + UnityEditor.Formats.Fbx.Exporter.ModelExporter.ExportObjects(fbxPath, new Object[] { root }, + new UnityEditor.Formats.Fbx.Exporter.ExportModelOptions { + ExportFormat = UnityEditor.Formats.Fbx.Exporter.ExportFormat.Binary, + // Embedded maps make the FBX self-contained; a linked FBX would + // reference this machine's absolute paths instead. + EmbedTextures = embedTextures }); + var info = new FileInfo(Path.GetFullPath(fbxPath)); + if (!info.Exists || info.Length == 0) + throw new IOException($"FBX Exporter produced an empty/missing file at '{fbxPath}'."); +#else + throw new InvalidOperationException("FBX Exporter package not installed."); +#endif + } + + /// + /// Writes to `.tmp`, verifies it, and replaces the + /// target in one rename (a plain move for a new path), keeping the target's `.meta` + /// as it was. If the exporter throws or writes an empty file the target on disk is + /// untouched. Throws on failure after removing the temp file. + /// + internal static void WriteAtomic(string targetFbxPath, GameObject root) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + string fullPath = Path.GetFullPath(targetFbxPath); + // Hash the full path so two FBX files with the same filename get distinct + // backup names. unchecked cast rather than Math.Abs — Math.Abs(int.MinValue) throws. + string pathHash = unchecked((uint)fullPath.GetHashCode()).ToString("X8"); + string metaBak = Path.Combine(Path.GetTempPath(), Path.GetFileName(fullPath) + "." + pathHash + ".meta.bak"); + bool metaBackedUp = File.Exists(fullPath + MetaExtension); + if (metaBackedUp) File.Copy(fullPath + MetaExtension, metaBak, true); + + string tmpRelPath = targetFbxPath + ".tmp"; + string tmpAbsPath = Path.GetFullPath(tmpRelPath); + if (File.Exists(tmpAbsPath)) File.Delete(tmpAbsPath); // leftover from a crashed run + try + { + UnityEditor.Formats.Fbx.Exporter.ModelExporter.ExportObjects(tmpRelPath, new Object[] { root }, + new UnityEditor.Formats.Fbx.Exporter.ExportModelOptions + { ExportFormat = UnityEditor.Formats.Fbx.Exporter.ExportFormat.Binary }); + + var tmpInfo = new FileInfo(tmpAbsPath); + if (!tmpInfo.Exists || tmpInfo.Length == 0) + throw new IOException($"FBX Exporter produced an empty/missing file at '{tmpRelPath}'."); + + // File.Replace needs an existing target (overwrite + backup); a fresh path + // is a move. + if (File.Exists(fullPath)) + { + string fbxBak = Path.Combine(Path.GetTempPath(), Path.GetFileName(fullPath) + "." + pathHash + ".fbx.bak"); + File.Replace(tmpAbsPath, fullPath, fbxBak); + if (File.Exists(fbxBak)) File.Delete(fbxBak); + } + else + { + File.Move(tmpAbsPath, fullPath); + } + + // The exporter may have generated a .meta for the .tmp — strip it so the + // AssetDatabase does not pick up a ghost asset on the next refresh. + string tmpMetaPath = tmpAbsPath + MetaExtension; + if (File.Exists(tmpMetaPath)) File.Delete(tmpMetaPath); + + if (metaBackedUp && File.Exists(metaBak)) + { + File.Copy(metaBak, fullPath + MetaExtension, true); + File.Delete(metaBak); + } + } + catch + { + // Best-effort: drop a leftover .tmp so a retry is not blocked. The original + // error matters, so this never throws. + try { if (File.Exists(tmpAbsPath)) File.Delete(tmpAbsPath); } + catch { /* the leftover .tmp is cosmetic; the exception below is the real error */ } + throw; + } +#else + throw new InvalidOperationException("FBX Exporter package not installed."); +#endif + } + + // ───────────────────────────────────────────────────────────────── + // LOD-rebuild hierarchy + // ───────────────────────────────────────────────────────────────── + + /// + /// If the cloned FBX carries its visible mesh on the root object, moves it to a new + /// child named after the mesh so the rest of the pipeline treats it as a LOD entry + /// ( then renames it to `baseName_LOD0`). + /// Skipped when a direct child already holds that mesh. + /// + internal static void PromoteRootMeshToLod0Child(GameObject tempRoot) + { + if (tempRoot == null) return; + var rootMf = tempRoot.GetComponent(); + if (rootMf == null || rootMf.sharedMesh == null) return; + var rootMr = tempRoot.GetComponent(); + var rootMesh = rootMf.sharedMesh; + + for (int ci = 0; ci < tempRoot.transform.childCount; ci++) + { + var existing = tempRoot.transform.GetChild(ci).GetComponent(); + if (existing != null && existing.sharedMesh == rootMesh) return; + } + + string childName = rootMesh.name; + if (string.IsNullOrEmpty(childName)) childName = tempRoot.name; + + var lod0 = new GameObject(childName); + lod0.transform.SetParent(tempRoot.transform, false); + lod0.transform.localPosition = Vector3.zero; + lod0.transform.localRotation = Quaternion.identity; + lod0.transform.localScale = Vector3.one; + lod0.AddComponent().sharedMesh = rootMesh; + if (rootMr != null) + { + var newMr = lod0.AddComponent(); + RendererSettings.Copy(rootMr, newMr); + GameObjectUtility.SetStaticEditorFlags(lod0, GameObjectUtility.GetStaticEditorFlags(tempRoot)); + Object.DestroyImmediate(rootMr); + } + Object.DestroyImmediate(rootMf); + } + + /// The renderer of the highest LOD among the entries; the template for new LOD children. + internal static Renderer FindLastLodRenderer(IEnumerable<(MeshEntry entry, Mesh resultMesh)> entries) + { + Renderer best = null; + int bestLod = int.MinValue; + foreach (var (entry, _) in entries) + { + if (entry?.renderer == null) continue; + if (entry.lodIndex >= bestLod) { bestLod = entry.lodIndex; best = entry.renderer; } + } + return best; + } + + /// + /// Swaps the clone's meshes for the export meshes of the same name (MeshFilters and + /// SkinnedMeshRenderers) and copies the matching scene renderer's settings onto the + /// node. Returns the names that were replaced. + /// + internal static HashSet ReplaceMeshes(GameObject tempRoot, Dictionary replacements, Dictionary rendererTemplates) + { + var replaced = new HashSet(); + foreach (var mf in tempRoot.GetComponentsInChildren(true)) + { + if (mf.sharedMesh == null || !replacements.TryGetValue(mf.sharedMesh.name, out var replacement)) continue; + string meshName = mf.sharedMesh.name; + replaced.Add(meshName); + mf.sharedMesh = replacement; + if (rendererTemplates.TryGetValue(meshName, out var srcRenderer)) + { + var dstRenderer = mf.GetComponent(); + if (dstRenderer != null) RendererSettings.Copy(srcRenderer, dstRenderer); + } + } + foreach (var smr in tempRoot.GetComponentsInChildren(true)) + { + if (smr.sharedMesh == null || !replacements.TryGetValue(smr.sharedMesh.name, out var replacement)) continue; + string meshName = smr.sharedMesh.name; + replaced.Add(meshName); + smr.sharedMesh = replacement; + if (rendererTemplates.TryGetValue(meshName, out var srcRenderer)) + RendererSettings.Copy(srcRenderer, smr); + } + return replaced; + } + + /// + /// Adds a LOD child for a mesh the clone did not have (a generated LOD): a direct + /// child named at the entry renderer's local transform, + /// replacing any same-named child from a previous export. Renderer settings and + /// static flags come from (the last LOD's renderer) or + /// the entry's own renderer. + /// + internal static void AddLodChild(GameObject tempRoot, string meshName, Mesh exportMesh, Renderer entryRenderer, Renderer template) + { + for (int ci = tempRoot.transform.childCount - 1; ci >= 0; ci--) + { + var ch = tempRoot.transform.GetChild(ci); + if (ch.name == meshName) Object.DestroyImmediate(ch.gameObject); + } + var child = new GameObject(meshName); + child.transform.SetParent(tempRoot.transform, false); + if (entryRenderer != null) + { + child.transform.localPosition = entryRenderer.transform.localPosition; + child.transform.localRotation = entryRenderer.transform.localRotation; + child.transform.localScale = entryRenderer.transform.localScale; + } + child.AddComponent().sharedMesh = exportMesh; + var mr = child.AddComponent(); + var from = template ?? entryRenderer; + if (from != null) + { + RendererSettings.Copy(from, mr); + GameObjectUtility.SetStaticEditorFlags(child, GameObjectUtility.GetStaticEditorFlags(from.gameObject)); + } + } + + /// + /// Destroys the clone's direct children that render a mesh outside + /// (by node name or mesh name). Collision nodes, + /// nodes with a MeshCollider or whose mesh a collider uses, and container nodes + /// without a mesh are kept. Must run before , + /// which renames LOD0. Returns the number pruned. + /// + internal static int PruneStaleChildren(GameObject tempRoot, HashSet validMeshNames) + { + var colliderMeshNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var colliderRootNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var mc in tempRoot.GetComponentsInChildren(true)) + { + if (mc == null) continue; + colliderRootNames.Add(mc.gameObject.name); + if (mc.sharedMesh != null && !string.IsNullOrEmpty(mc.sharedMesh.name)) + colliderMeshNames.Add(mc.sharedMesh.name); + } + + int pruned = 0; + for (int ci = tempRoot.transform.childCount - 1; ci >= 0; ci--) + { + var ch = tempRoot.transform.GetChild(ci); + if (MeshNaming.IsCollision(ch.name)) continue; + if (colliderRootNames.Contains(ch.name)) continue; + var chMf = ch.GetComponent(); + if (chMf != null && chMf.sharedMesh != null && colliderMeshNames.Contains(chMf.sharedMesh.name)) continue; + var chSmr = ch.GetComponent(); + string childMeshName = chMf != null && chMf.sharedMesh != null ? chMf.sharedMesh.name + : chSmr != null && chSmr.sharedMesh != null ? chSmr.sharedMesh.name : null; + // Structural/container nodes stay: removing them flattens the hierarchy + // and can break prefabs. + if (childMeshName == null) continue; + // Some imports name the node differently from its mesh (root LOD0 + // especially); either name counts. + if (validMeshNames.Contains(ch.name) || validMeshNames.Contains(childMeshName)) continue; + UvtLog.Verbose($"[FBX Export] Pruning stale child '{ch.name}'"); + Object.DestroyImmediate(ch.gameObject); + pruned++; + } + return pruned; + } + + /// + /// Normalizes the export hierarchy: root transform reset to identity, the direct + /// child named like the root renamed `_LOD0`, each LOD chain renumbered to + /// contiguous `_LOD0.._LODN` per prefix, every child's transform baked into a copy + /// of its mesh and reset. Returns oldName → newName for the renamed nodes (for the + /// post-reimport scene relink). Every mesh copy goes to ; + /// the caller destroys them after the write. + /// + internal static Dictionary NormalizeExportHierarchy(GameObject root, List bakedMeshSink) + { + if (bakedMeshSink == null) throw new ArgumentNullException(nameof(bakedMeshSink)); + + var renameMap = new Dictionary(); + string baseName = root.name; + string sanitizedBaseName = MeshHygieneUtility.SanitizeName(baseName); + if (string.IsNullOrEmpty(sanitizedBaseName)) sanitizedBaseName = "Unnamed"; + + root.transform.localPosition = Vector3.zero; + root.transform.localRotation = Quaternion.identity; + root.transform.localScale = Vector3.one; + + foreach (Transform child in root.transform) + { + if (child.name == baseName || child.name == sanitizedBaseName) + { + string oldName = child.name; + child.name = sanitizedBaseName + "_LOD0"; + if (oldName != child.name) renameMap[oldName] = child.name; + break; + } + } + + // Contiguous LOD numbering PER GROUP (the child's own prefix before `_LOD`), + // so several LOD chains under one root keep their prefixes. Prevents importer + // warnings ("_LOD1 found but no _LOD0") after inconsistent sanitising. + var groupedLodChildren = new Dictionary>(); + var groupOrder = new List(); + foreach (Transform child in root.transform) + { + if (MeshNaming.IsCollision(child.name)) continue; + var mf = child.GetComponent(); + var smr = child.GetComponent(); + bool hasMesh = (mf != null && mf.sharedMesh != null) || (smr != null && smr.sharedMesh != null); + if (!hasMesh) continue; + + // Any LOD index the name carries, even one the LODGroup cannot hold: this + // pass only groups and orders children. + string groupPrefix = MeshNaming.SplitLodSuffix(child.name, out string lodSuffix); + if (lodSuffix.Length == 0 || + !int.TryParse(lodSuffix.Substring(lodSuffix.ToUpperInvariant().LastIndexOf("LOD") + 3), out int parsedIndex)) + continue; + if (!groupedLodChildren.TryGetValue(groupPrefix, out var list)) + { + groupedLodChildren[groupPrefix] = list = new List<(Transform, int, int)>(); + groupOrder.Add(groupPrefix); + } + list.Add((child, parsedIndex, child.GetSiblingIndex())); + } + foreach (var groupPrefix in groupOrder) + { + var list = groupedLodChildren[groupPrefix]; + list.Sort((a, b) => + { + int cmp = a.index.CompareTo(b.index); + return cmp != 0 ? cmp : a.siblingIndex.CompareTo(b.siblingIndex); + }); + for (int i = 0; i < list.Count; i++) + { + string normalizedName = groupPrefix + "_LOD" + i; + string oldName = list[i].transform.name; + if (oldName == normalizedName) continue; + list[i].transform.name = normalizedName; + renameMap[oldName] = normalizedName; + } + } + + // Bake non-identity transforms into the vertices (an FBX imported at 0.01 with a + // compensating 100× node scale is the common case) so every exported node has + // a clean identity transform. + foreach (var childMf in root.GetComponentsInChildren(true)) + { + if (childMf == null || childMf.sharedMesh == null) continue; + if (childMf.transform == root.transform) continue; + var t = childMf.transform; + if (t.localPosition == Vector3.zero && t.localRotation == Quaternion.identity && t.localScale == Vector3.one) + continue; + var mesh = childMf.sharedMesh; + if (!mesh.isReadable) continue; + + // Several nodes can instance the same Mesh; baking into it directly would + // apply each node's transform cumulatively. Every node gets its own copy. + var bakedMesh = Object.Instantiate(mesh); + bakedMesh.name = mesh.name; + childMf.sharedMesh = bakedMesh; + bakedMeshSink.Add(bakedMesh); + MeshTransform.BakeMatrix(bakedMesh, Matrix4x4.TRS(t.localPosition, t.localRotation, t.localScale)); + t.localPosition = Vector3.zero; + t.localRotation = Quaternion.identity; + t.localScale = Vector3.one; + } + return renameMap; + } + + /// + /// Adds the sidecar's collision meshes as `_COL` children (one node for a + /// simplified collider, a container with `_COL_Hull{N}` children for a convex + /// decomposition), removing the clone's existing `_COL` children first when there + /// is anything to add. The meshes go to . Returns the + /// number of collision meshes placed. + /// + internal static int InjectCollisionMeshes(GameObject tempRoot, List<(string meshName, List meshes, bool isConvex)> collisionData, List tempSink) + { + if (tempSink == null) throw new ArgumentNullException(nameof(tempSink)); + if (collisionData == null || collisionData.Count == 0) return 0; + + for (int ci = tempRoot.transform.childCount - 1; ci >= 0; ci--) + { + var ch = tempRoot.transform.GetChild(ci); + if (MeshNaming.IsCollision(ch.name)) Object.DestroyImmediate(ch.gameObject); + } + + int count = 0; + foreach (var (colMeshName, colMeshes, isConvex) in collisionData) + { + tempSink.AddRange(colMeshes); + if (colMeshes.Count == 1 && !isConvex) + { + // No MeshRenderer: avoids a stale material on the node. + var colChild = new GameObject(colMeshName + "_COL"); + colChild.transform.SetParent(tempRoot.transform, false); + colChild.AddComponent().sharedMesh = colMeshes[0]; + count++; + } + else + { + var container = new GameObject(colMeshName + "_COL"); + container.transform.SetParent(tempRoot.transform, false); + for (int hi = 0; hi < colMeshes.Count; hi++) + { + var hullChild = new GameObject($"{colMeshName}_COL_Hull{hi}"); + hullChild.transform.SetParent(container.transform, false); + hullChild.AddComponent().sharedMesh = colMeshes[hi]; + count++; + } + } + } + if (count > 0) UvtLog.Verbose($"[FBX Export] Added {count} collision mesh(es) from sidecar"); + return count; + } + + /// + /// Gives every `_COL` node a renderer with (so the + /// exporter does not write a default "Lit" material), or destroys its renderer + /// when there is no material to give. + /// + internal static void PrepareCollisionMaterials(GameObject tempRoot, Material fallback) + { + foreach (var colMf in tempRoot.GetComponentsInChildren(true)) + { + if (colMf == null || colMf.sharedMesh == null) continue; + if (!MeshNaming.IsCollision(colMf.gameObject.name)) continue; + var colMr = colMf.GetComponent(); + if (fallback != null) + { + if (colMr == null) colMr = colMf.gameObject.AddComponent(); + colMr.sharedMaterials = new[] { fallback }; + } + else if (colMr != null) + Object.DestroyImmediate(colMr); + } + } + + /// + /// Reduces every readable `_COL` mesh to positions, triangles, recalculated normals + /// and (only when the source had them) synthesized tangents — no UVs or colours — + /// after . The stripped copies go to + /// ; a Read/Write-disabled collision sub-asset is warned + /// about and left alone. + /// + internal static void StripCollisionMeshes(GameObject tempRoot, Material fallback, List tempSink) + { + if (tempSink == null) throw new ArgumentNullException(nameof(tempSink)); + PrepareCollisionMaterials(tempRoot, fallback); + foreach (var colMf in tempRoot.GetComponentsInChildren(true)) + { + if (colMf == null || colMf.sharedMesh == null) continue; + if (!MeshNaming.IsCollision(colMf.gameObject.name)) continue; + var srcCol = colMf.sharedMesh; + if (!srcCol.isReadable) + { + UvtLog.Warn($"[FBX Export] Collision mesh '{srcCol.name}' is not readable — " + + "skipping strip. Re-save collision to sidecar to fix."); + continue; + } + var stripped = new Mesh { name = srcCol.name }; + tempSink.Add(stripped); + stripped.SetVertices(srcCol.vertices); + for (int s = 0; s < srcCol.subMeshCount; s++) + stripped.SetTriangles(srcCol.GetTriangles(s), s); + stripped.RecalculateNormals(); + if (TangentValidator.HasTangents(srcCol)) + { + var normals = stripped.normals; + var tangents = new Vector4[normals.Length]; + for (int ti = 0; ti < normals.Length; ti++) + { + Vector3 n = normals[ti]; + Vector3 t = Vector3.Cross(n, Vector3.up); + if (t.sqrMagnitude < 0.001f) t = Vector3.Cross(n, Vector3.right); + t.Normalize(); + tangents[ti] = new Vector4(t.x, t.y, t.z, 1f); + } + stripped.tangents = tangents; + TangentValidator.ValidateTangentsW(tangents, stripped.name, "FBX Export (collision)"); + } + stripped.RecalculateBounds(); + colMf.sharedMesh = stripped; + } + } + + /// Cuts every renderer's material array down to its mesh's submesh count (no spurious default "Lit" slots). + internal static void TrimMaterialArrays(GameObject tempRoot) + { + foreach (var mr in tempRoot.GetComponentsInChildren(true)) + { + if (mr == null) continue; + var mf = mr.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + var mats = mr.sharedMaterials; + if (mats.Length <= mf.sharedMesh.subMeshCount) continue; + UvtLog.Verbose($"[FBX Export] Trimming materials on '{mr.gameObject.name}': {mats.Length} → {mf.sharedMesh.subMeshCount}"); + var trimmed = new Material[mf.sharedMesh.subMeshCount]; + Array.Copy(mats, trimmed, trimmed.Length); + mr.sharedMaterials = trimmed; + } + } + + /// + /// Destroys the temporary meshes an export built. DestroyImmediate on the temp root + /// frees only GameObjects, so the copies have to be released explicitly — and only + /// after the write, which reads them. + /// + internal static void DestroyTempMeshes(List meshes) + { + if (meshes == null) return; + foreach (var mesh in meshes) + if (mesh != null) Object.DestroyImmediate(mesh); + meshes.Clear(); + } + + // ───────────────────────────────────────────────────────────────── + // After the reimport + // ───────────────────────────────────────────────────────────────── + + /// + /// Re-binds the MeshFilters and MeshColliders under to + /// the reimported FBX's sub-assets (Unity recreates them, so old references go + /// Missing even when names did not change), by mesh name, node name, the + /// (optional) or a fuzzy name match, and renames + /// scene nodes that the export renamed. Records Undo for every change. + /// + internal static void RelinkSceneMeshReferences(string fbxPath, Dictionary renameMap, LODGroup lodGroup) + { + if (lodGroup == null) return; + + var meshByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var asset in AssetDatabase.LoadAllAssetsAtPath(fbxPath)) + if (asset is Mesh mesh && !meshByName.ContainsKey(mesh.name)) meshByName[mesh.name] = mesh; + if (meshByName.Count == 0) return; + + int relinked = 0; + var root = lodGroup.transform; + + foreach (var mf in root.GetComponentsInChildren(true)) + { + if (mf == null) continue; + if (mf.sharedMesh != null) + { + string meshName = mf.sharedMesh.name; + if (renameMap != null && renameMap.TryGetValue(meshName, out string newName)) meshName = newName; + if (meshByName.TryGetValue(meshName, out var freshMesh) && mf.sharedMesh != freshMesh) + { + Undo.RecordObject(mf, RelinkUndoLabel); + mf.sharedMesh = freshMesh; + relinked++; + } + continue; + } + + // Missing mesh — by node name, then the renamed name, then fuzzy. + string goName = mf.gameObject.name; + if (meshByName.TryGetValue(goName, out var match)) + { + Undo.RecordObject(mf, RelinkUndoLabel); + mf.sharedMesh = match; + relinked++; + continue; + } + if (renameMap != null) + { + foreach (var kvp in renameMap) + { + if (goName != kvp.Key || !meshByName.TryGetValue(kvp.Value, out var renamedMesh)) continue; + Undo.RecordObject(mf, RelinkUndoLabel); + mf.sharedMesh = renamedMesh; + relinked++; + break; + } + } + if (mf.sharedMesh == null) + { + foreach (var kvp in meshByName) + { + if (!goName.Contains(kvp.Key) && !kvp.Key.Contains(goName)) continue; + Undo.RecordObject(mf, RelinkUndoLabel); + mf.sharedMesh = kvp.Value; + relinked++; + break; + } + } + } + + foreach (var mc in root.GetComponentsInChildren(true)) + { + if (mc == null) continue; + if (mc.sharedMesh != null) + { + string meshName = mc.sharedMesh.name; + if (renameMap != null && renameMap.TryGetValue(meshName, out string newName)) meshName = newName; + if (meshByName.TryGetValue(meshName, out var freshMesh) && mc.sharedMesh != freshMesh) + { + Undo.RecordObject(mc, "Relink Collider Mesh"); + mc.sharedMesh = freshMesh; + relinked++; + } + } + else if (meshByName.TryGetValue(mc.gameObject.name, out var match)) + { + Undo.RecordObject(mc, "Relink Collider Mesh"); + mc.sharedMesh = match; + relinked++; + } + } + + if (renameMap != null) + { + foreach (var kvp in renameMap) + { + foreach (Transform child in root) + { + if (child.name != kvp.Key) continue; + Undo.RecordObject(child.gameObject, "Rename to match FBX"); + child.name = kvp.Value; + break; + } + } + } + + if (relinked > 0) + UvtLog.Info($"[FBX Export] Relinked {relinked} mesh reference(s) after reimport."); + } + + /// + /// Removes the importer's external material remaps named `Lit` or `No Name` (the + /// defaults it invents for collision-only nodes) and reimports when any were + /// found; runs first so the caller can re-arm a + /// transient sidecar replay. Returns true when the importer changed. + /// + internal static bool RemoveDefaultMaterialRemaps(string fbxPath, Action beforeReimport) + { + var imp = AssetImporter.GetAtPath(fbxPath) as ModelImporter; + if (imp == null) return false; + var toRemove = new List(); + foreach (var kvp in imp.GetExternalObjectMap()) + { + if (kvp.Key.type != typeof(Material)) continue; + if (kvp.Key.name == "Lit" || kvp.Key.name == "No Name") toRemove.Add(kvp.Key); + } + if (toRemove.Count == 0) return false; + beforeReimport?.Invoke(); + foreach (var key in toRemove) imp.RemoveRemap(key); + imp.SaveAndReimport(); + return true; + } + } +} diff --git a/Editor/Assets/FbxExport.cs.meta b/Editor/Assets/FbxExport.cs.meta new file mode 100644 index 00000000..c14d8506 --- /dev/null +++ b/Editor/Assets/FbxExport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43f090810d60452db4e92b5d94b29cbe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Assets/SidecarStore.cs b/Editor/Assets/SidecarStore.cs new file mode 100644 index 00000000..610debe4 --- /dev/null +++ b/Editor/Assets/SidecarStore.cs @@ -0,0 +1,487 @@ +// SidecarStore.cs — the one reader and writer of the `_uv2data.asset` sidecar that +// travels next to an FBX: UV2 entries, collision hulls and the transfer tool's +// settings. Tools used to open the asset themselves (load-or-create, Set, SetDirty, +// SaveAssets) in six places and to collect "the FBX paths behind these entries" in +// five; every one of those goes through here now. +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + /// + /// Persistence of sidecars. Nothing here touches the + /// scene or an importer; the postprocessor's replay sets are armed only by + /// , which the export calls when sidecar mode is off. + /// + internal static class SidecarStore + { + /// `Assets/Models/Chair.fbx` → `Assets/Models/Chair_uv2data.asset`. + internal static string PathFor(string fbxPath) => Uv2DataAsset.GetSidecarPath(fbxPath); + + /// The sidecar next to , or null when there is none. + internal static Uv2DataAsset Load(string fbxPath) + { + if (string.IsNullOrEmpty(fbxPath)) return null; + return AssetDatabase.LoadAssetAtPath(PathFor(fbxPath)); + } + + /// The sidecar next to , created on disk when there is none. + internal static Uv2DataAsset LoadOrCreate(string fbxPath) + { + string sidecarPath = PathFor(fbxPath); + var data = AssetDatabase.LoadAssetAtPath(sidecarPath); + if (data == null) + { + data = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(data, sidecarPath); + } + return data; + } + + /// True when a sidecar exists next to . + internal static bool Exists(string fbxPath) => Load(fbxPath) != null; + + /// Marks the sidecar dirty and writes every pending asset change. + internal static void Save(Uv2DataAsset data) + { + if (data == null) return; + EditorUtility.SetDirty(data); + AssetDatabase.SaveAssets(); + } + + // ── FBX paths behind a selection ── + + /// + /// The distinct `.fbx` asset paths the entries were imported from (the FBX + /// sub-asset first, the working mesh when there is none), in entry order. + /// + internal static List FbxPaths(IEnumerable entries) + { + var paths = new List(); + if (entries == null) return paths; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var e in entries) + { + if (e == null) continue; + Mesh m = e.fbxMesh ?? e.originalMesh; + if (m == null) continue; + string p = AssetDatabase.GetAssetPath(m); + if (IsFbxPath(p) && seen.Add(p)) paths.Add(p); + } + return paths; + } + + /// The `.fbx` asset paths under the meshes of 's MeshFilters. + internal static List FbxPaths(GameObject root) + { + var paths = new List(); + if (root == null) return paths; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var mf in root.GetComponentsInChildren()) + { + if (mf.sharedMesh == null) continue; + string p = AssetDatabase.GetAssetPath(mf.sharedMesh); + if (IsFbxPath(p) && seen.Add(p)) paths.Add(p); + } + return paths; + } + + /// True for a non-empty path ending in `.fbx` (any case). + internal static bool IsFbxPath(string path) + => !string.IsNullOrEmpty(path) && path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase); + + /// + /// The first of that has a sidecar, with that sidecar's + /// path; false when none has one. + /// + internal static bool TryFindFirst(IEnumerable fbxPaths, out string fbxPath, out string sidecarPath) + { + fbxPath = sidecarPath = null; + if (fbxPaths == null) return false; + foreach (string fbx in fbxPaths) + { + string sp = PathFor(fbx); + if (AssetDatabase.LoadAssetAtPath(sp) == null) continue; + fbxPath = fbx; + sidecarPath = sp; + return true; + } + return false; + } + + // ── UV2 entries ── + + /// + /// Adds or replaces in the sidecar next to + /// , creating it when needed. Returns the number stored. + /// + internal static int SaveEntries(string fbxPath, IEnumerable entries) + { + if (string.IsNullOrEmpty(fbxPath) || entries == null) return 0; + var data = LoadOrCreate(fbxPath); + int saved = 0; + foreach (var entry in entries) + { + if (entry == null) continue; + data.Set(entry); + saved++; + } + if (saved > 0) Save(data); + return saved; + } + + /// + /// Drops the UV2 entries of every sidecar behind . + /// Collision entries are kept; a sidecar left with nothing is deleted. + /// Returns the number of sidecars that had UV2 entries. + /// + internal static int ClearUv2Entries(IEnumerable fbxPaths) + { + int cleared = 0; + if (fbxPaths == null) return 0; + foreach (var fbxPath in fbxPaths) + { + if (string.IsNullOrEmpty(fbxPath)) continue; + string sidecarPath = PathFor(fbxPath); + var data = AssetDatabase.LoadAssetAtPath(sidecarPath); + if (data == null) continue; + + bool hasCollision = data.collisionEntries != null && data.collisionEntries.Count > 0; + bool hasUv2 = data.entries != null && data.entries.Count > 0; + if (!hasUv2) continue; + + data.entries.Clear(); + cleared++; + if (hasCollision) EditorUtility.SetDirty(data); + else AssetDatabase.DeleteAsset(sidecarPath); + } + if (cleared > 0) AssetDatabase.SaveAssets(); + return cleared; + } + + /// Deletes the sidecars behind . Returns how many existed. + internal static int Delete(IEnumerable fbxPaths) + { + int deleted = 0; + if (fbxPaths == null) return 0; + foreach (string fbx in fbxPaths) + { + if (string.IsNullOrEmpty(fbx)) continue; + string sp = PathFor(fbx); + if (AssetDatabase.LoadAssetAtPath(sp) == null) continue; + AssetDatabase.DeleteAsset(sp); + deleted++; + } + return deleted; + } + + /// + /// Hands to the postprocessor for the next import of + /// only (sidecar mode off): the UV2 is replayed once and + /// nothing is written to disk. + /// + internal static void ArmTransientReplay(string fbxPath, List entries) + { + if (string.IsNullOrEmpty(fbxPath) || entries == null || entries.Count == 0) return; + Uv2AssetPostprocessor.SetTransientReplayEntries(fbxPath, entries); + Uv2AssetPostprocessor.managedImportPaths.Add(fbxPath); + Uv2AssetPostprocessor.transientReplayPaths.Add(fbxPath); + } + + /// + /// Stores for the way the + /// current sidecar mode wants: on disk when it is on, as a one-shot replay when + /// it is off. Either way the postprocessor treats the next import as managed. + /// + internal static void StoreForImport(string fbxPath, List entries, bool persistent) + { + if (persistent) SaveEntries(fbxPath, entries); + else Uv2AssetPostprocessor.SetTransientReplayEntries(fbxPath, entries); + Uv2AssetPostprocessor.managedImportPaths.Add(fbxPath); + if (!persistent) Uv2AssetPostprocessor.transientReplayPaths.Add(fbxPath); + } + + // ── Entry construction ── + + /// + /// Which UV component vertex AO was written to, as the export needs it: a UV + /// channel (0–7) and component (0 = X, 1 = Y). when AO went to a + /// vertex colour channel, nothing was applied, or the channel is UV1 — the lightmap + /// channel, which the export never merges AO into. + /// + internal readonly struct AoUvTarget + { + public readonly int channel; + public readonly int component; + public AoUvTarget(int channel, int component) { this.channel = channel; this.component = component; } + public bool IsSet => channel >= 0; + public static readonly AoUvTarget None = new AoUvTarget(-1, 0); + + /// The target behind a vertex-AO channel choice, honouring the UV1 rule. + public static AoUvTarget From(AOTargetChannel? applied) + { + if (!applied.HasValue) return None; + int channel = VertexChannels.UvChannel(applied.Value); + if (channel < 0 || channel == 1) return None; + return new AoUvTarget(channel, VertexChannels.UvComponent(applied.Value)); + } + } + + /// + /// The UV2 sidecar entry for one exported mesh: the result's UV1 (or the AO + /// channel when UV1 is absent), positions, UV0, colours, the source fingerprint + /// and the pipeline steps the entry went through. False when the result has no + /// UV1 and no AO channel to store. + /// + /// True when the entry is the repack (source) LOD, false for a transfer target. + internal static bool TryBuildEntry(MeshEntry entry, Mesh resultMesh, bool isSourceLod, AoUvTarget ao, out MeshUv2Entry sidecarEntry) + { + sidecarEntry = null; + if (entry == null || resultMesh == null) return false; + + var sidecarMesh = UnityEngine.Object.Instantiate(resultMesh); + sidecarMesh.name = resultMesh.name; + try + { + if (entry.fbxMesh != null) + FbxExport.PreserveUvChannels(sidecarMesh, entry.fbxMesh); + if (entry.originalMesh != null && entry.originalMesh != entry.fbxMesh) + { + FbxExport.PreserveUvChannels(sidecarMesh, entry.originalMesh); + FbxExport.OverwriteUvChannel(sidecarMesh, entry.originalMesh, 1); + } + + // TBN: keep tangent presence in sync with the source FBX. If the FBX + // import did not produce tangents, do not let derived/welded meshes + // smuggle a synthesized tangent stream into the sidecar payload. + TangentValidator.EnforceTangentsMatchOriginal(sidecarMesh, entry.fbxMesh, "Sidecar"); + + Vector2[] auxiliaryUv = null; + int auxiliaryTargetUvChannel = -1; + if (ao.IsSet) + { + var uvDonor = FbxExport.SelectUvDonor(entry, resultMesh, ao.channel); + if (uvDonor != null) + { + FbxExport.MergeUvComponentFromDonor(sidecarMesh, uvDonor, ao.channel, ao.component); + var auxiliaryUvList = new List(); + sidecarMesh.GetUVs(ao.channel, auxiliaryUvList); + if (auxiliaryUvList.Count == sidecarMesh.vertexCount) + { + auxiliaryUv = auxiliaryUvList.ToArray(); + auxiliaryTargetUvChannel = ao.channel; + } + } + } + + var primaryUvList = new List(); + sidecarMesh.GetUVs(1, primaryUvList); + Vector2[] primaryUv = primaryUvList.Count == sidecarMesh.vertexCount ? primaryUvList.ToArray() : null; + int primaryTargetUvChannel = 1; + if (primaryUv == null && auxiliaryUv != null) + { + primaryUv = auxiliaryUv; + primaryTargetUvChannel = auxiliaryTargetUvChannel; + auxiliaryUv = null; + auxiliaryTargetUvChannel = -1; + } + if (primaryUv == null) return false; + + var positions = sidecarMesh.vertices; + var colors = sidecarMesh.colors32; + var uv0List = new List(); + (entry.originalMesh ?? resultMesh).GetUVs(0, uv0List); + + string meshName = entry.fbxMesh != null + ? entry.fbxMesh.name + : (entry.originalMesh != null ? entry.originalMesh.name : resultMesh.name); + MeshFingerprint fp = entry.fbxMesh != null ? MeshFingerprint.Compute(entry.fbxMesh) : null; + + sidecarEntry = new MeshUv2Entry + { + meshName = meshName, + uv2 = primaryUv, + welded = entry.wasWelded, + edgeWelded = entry.wasEdgeWelded, + vertPositions = positions, + vertUv0 = uv0List.ToArray(), + optimizedColors = colors.Length == sidecarMesh.vertexCount ? colors : null, + schemaVersion = Uv2DataAsset.CurrentSchemaVersion, + toolVersion = Uv2DataAsset.ToolVersionStr, + sourceFingerprint = fp, + targetUvChannel = primaryTargetUvChannel, + auxiliaryUv = auxiliaryUv, + auxiliaryTargetUvChannel = auxiliaryTargetUvChannel, + stepMeshopt = entry.wasWelded, + stepEdgeWeld = entry.wasEdgeWelded, + stepSymmetrySplit = entry.wasSymmetrySplit, + stepRepack = isSourceLod, + stepTransfer = !isSourceLod, + }; + return true; + } + finally + { + UnityEngine.Object.DestroyImmediate(sidecarMesh); + } + } + + // ── Collision entries ── + + // 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 MaxHullCount = 1024; + const int MaxVertexCount = 1_000_000; + const int MaxIndexCount = 3_000_000; + + /// + /// The collision meshes stored next to , one list per + /// entry: a single mesh for a simplified collider, one per hull for a convex + /// decomposition. The meshes are new objects the caller owns and destroys. + /// Invalid entries are logged and skipped. + /// + internal static List<(string meshName, List meshes, bool isConvex)> CollisionMeshes(string fbxPath) + { + var result = new List<(string, List, bool)>(); + var data = Load(fbxPath); + if (data == null || data.collisionEntries == null) return result; + + 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; + 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; + + int vertCount = posEnd - posStart; + var verts = new Vector3[vertCount]; + Array.Copy(entry.allPositions, posStart, verts, 0, vertCount); + + int idxCount = triEnd - triStart; + var tris = new int[idxCount]; + Array.Copy(entry.allTriangles, triStart, tris, 0, idxCount); + 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 ? $"{entry.meshGroupKey}_COL_Hull{h}" : $"{entry.meshGroupKey}_COL"; + mesh.SetVertices(verts); + mesh.SetTriangles(tris, 0); + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + meshes.Add(mesh); + } + result.Add((entry.meshGroupKey, meshes, isConvex)); + } + return result; + } + + /// + /// Checks a collision entry's arrays and offsets, and tells for each hull whether + /// its triangle indices address the flattened array (current) or the hull's own + /// vertices (legacy); both encodings are accepted. + /// + internal 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 > MaxHullCount) + 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 > MaxVertexCount || entry.allTriangles.Length > MaxIndexCount) + 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. Prefer the current global encoding when a 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; + } + + // ── Tool settings ── + + /// The transfer tool settings stored in the sidecar at , or null. + internal static ToolSettings LoadSettings(string sidecarPath) + { + if (string.IsNullOrEmpty(sidecarPath)) return null; + return AssetDatabase.LoadAssetAtPath(sidecarPath)?.toolSettings; + } + + /// + /// Lets fill the settings block of the sidecar at + /// (created when missing) and saves it. A no-op + /// when there is no sidecar. + /// + internal static void SaveSettings(string sidecarPath, Action edit) + { + if (string.IsNullOrEmpty(sidecarPath) || edit == null) return; + var data = AssetDatabase.LoadAssetAtPath(sidecarPath); + if (data == null) return; + if (data.toolSettings == null) data.toolSettings = new ToolSettings(); + edit(data.toolSettings); + Save(data); + } + } +} diff --git a/Editor/Assets/SidecarStore.cs.meta b/Editor/Assets/SidecarStore.cs.meta new file mode 100644 index 00000000..d848be98 --- /dev/null +++ b/Editor/Assets/SidecarStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e1679745b6f54c17a683e7e6d9e07cc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Assets/TextureAssets.cs b/Editor/Assets/TextureAssets.cs new file mode 100644 index 00000000..71f84e86 --- /dev/null +++ b/Editor/Assets/TextureAssets.cs @@ -0,0 +1,124 @@ +// TextureAssets.cs — pixels in and out of textures and texture files: a Texture2D from +// a pixel buffer (previews, encodes), PNG/EXR encoding through a transient texture, a +// file written with its directory, a PNG read back to pixels, and the importer +// configuration of a written map (colour, linear data or normal map). The remesh +// exporter, the remesh previews, the hierarchical repack diagnostics and the UV PNG +// writer each created their transient textures and wrote their files by hand. +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class TextureAssets + { + /// What a written map holds, for its importer. + internal enum Kind + { + /// sRGB colour (albedo). + Color, + /// Linear data (metallic/smoothness, occlusion, emission). + Linear, + /// A tangent-space normal map. + NormalMap, + } + + // ── textures from pixels ── + + /// + /// An RGBA32 texture holding (row-major, bottom row + /// first, as Unity stores them), applied and flagged HideAndDontSave: for a + /// preview or an encode, never an asset. marks the data + /// as not sRGB. The caller destroys it. + /// + internal static Texture2D FromPixels(Color32[] pixels, int width, int height, bool linear, bool mipmaps = false) + { + if (pixels == null || pixels.Length != width * height) return null; + var texture = new Texture2D(width, height, TextureFormat.RGBA32, mipmaps, linear) { hideFlags = HideFlags.HideAndDontSave }; + texture.SetPixels32(pixels); + texture.Apply(); + return texture; + } + + // ── encoding ── + + /// The pixels as a PNG, through a transient texture that is destroyed again. + internal static byte[] EncodePng(Color32[] pixels, int width, int height) + { + var texture = FromPixels(pixels, width, height, linear: true); + if (texture == null) return null; + try { return texture.EncodeToPNG(); } + finally { Object.DestroyImmediate(texture); } + } + + /// The HDR pixels as a float EXR, through a transient texture that is destroyed again. + internal static byte[] EncodeExr(Color[] pixels, int width, int height) + { + if (pixels == null || pixels.Length != width * height) return null; + var texture = new Texture2D(width, height, TextureFormat.RGBAFloat, false, true) { hideFlags = HideFlags.HideAndDontSave }; + try + { + texture.SetPixels(pixels); + texture.Apply(); + return texture.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat); + } + finally { Object.DestroyImmediate(texture); } + } + + // ── files ── + + /// Writes to , creating the directory. + internal static void WriteFile(string path, byte[] bytes) + { + if (string.IsNullOrEmpty(path) || bytes == null) return; + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllBytes(path, bytes); + } + + /// Encodes the pixels to PNG and writes the file ( + ). + internal static void WritePng(string path, Color32[] pixels, int width, int height) + => WriteFile(path, EncodePng(pixels, width, height)); + + /// + /// The pixels of a PNG or JPG file (bottom row first), with its size; null when + /// the file is missing or does not decode. + /// + internal static Color32[] ReadImage(string path, out int width, out int height) + { + width = height = 0; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false) { hideFlags = HideFlags.HideAndDontSave }; + try + { + if (!texture.LoadImage(File.ReadAllBytes(path))) return null; + width = texture.width; + height = texture.height; + return texture.GetPixels32(); + } + finally { Object.DestroyImmediate(texture); } + } + + // ── importers ── + + /// + /// Sets up the importer of a map the tool wrote: texture type and sRGB from + /// , clamp wrapping, the given maximum size, no + /// compression, mipmaps, alpha from the file; then reimports. Throws when the + /// path has no texture importer (the write or import failed). + /// + internal static void Configure(string assetPath, Kind kind, int maxSize, bool mipmaps = true) + { + var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter; + if (importer == null) throw new IOException("Texture import failed: " + assetPath); + importer.textureType = kind == Kind.NormalMap ? TextureImporterType.NormalMap : TextureImporterType.Default; + importer.sRGBTexture = kind == Kind.Color; + importer.wrapMode = TextureWrapMode.Clamp; + importer.maxTextureSize = maxSize; + importer.textureCompression = TextureImporterCompression.Uncompressed; + importer.mipmapEnabled = mipmaps; + importer.alphaSource = TextureImporterAlphaSource.FromInput; + importer.SaveAndReimport(); + } + } +} diff --git a/Editor/Assets/TextureAssets.cs.meta b/Editor/Assets/TextureAssets.cs.meta new file mode 100644 index 00000000..733e54b3 --- /dev/null +++ b/Editor/Assets/TextureAssets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43f2180935f74e3a8e759b321f549062 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Bench.meta b/Editor/Bench.meta new file mode 100644 index 00000000..023dc66d --- /dev/null +++ b/Editor/Bench.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0a354896b9cc44bba61431c9dfcd683b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BenchmarkRecorder.cs b/Editor/Bench/BenchmarkRecorder.cs similarity index 99% rename from Editor/BenchmarkRecorder.cs rename to Editor/Bench/BenchmarkRecorder.cs index 90e77ccf..d4929dc8 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/Bench/BenchmarkRecorder.cs @@ -128,7 +128,7 @@ public sealed class BenchmarkRecorder : IDisposable public static IDisposable NewRun(UvToolContext ctx, string label, bool splitTargets, SymmetrySplitShells.ThresholdMode symMode) { - if (!MeshLabProjectSettings.Instance.showDebugUI) return NoOpScope.Instance; + if (!DebugUi.Enabled) return NoOpScope.Instance; if (Current != null) return NoOpScope.Instance; Current = new BenchmarkRecorder(ctx, label, splitTargets, symMode); return Current; diff --git a/Editor/BenchmarkRecorder.cs.meta b/Editor/Bench/BenchmarkRecorder.cs.meta similarity index 100% rename from Editor/BenchmarkRecorder.cs.meta rename to Editor/Bench/BenchmarkRecorder.cs.meta diff --git a/Editor/Bench/BenchmarkRunner.cs b/Editor/Bench/BenchmarkRunner.cs new file mode 100644 index 00000000..7b688716 --- /dev/null +++ b/Editor/Bench/BenchmarkRunner.cs @@ -0,0 +1,201 @@ +// BenchmarkRunner.cs — the multi-model benchmark as a library: every case of a +// TestSuiteAsset spawned from its FBX, its LODGroup bound on the host, every enabled +// technique run into one per-case directory (legacy/ for the parameter sweep, hier/ +// for the hierarchical probe, repack dry-run and Stage D sweep), the spawn destroyed +// and the operator's own LODGroup restored whatever happens. +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + /// A sweep host that can also be pointed at another LODGroup for the duration of a case. + internal interface IBenchmarkHost : ISweepHost + { + /// Binds the host (context and tab) to ; null unbinds. + void Bind(LODGroup lodGroup); + } + + internal static class BenchmarkRunner + { + /// + /// Runs every case of with every technique in + /// `suite.techniques`. Artefacts land under + /// `BenchmarkReports/bench_<stamp>/<idx>_<label>/{legacy,hier}/`. + /// Returns the number of cases that produced anything. + /// + internal static int Run(IBenchmarkHost host, TestSuiteAsset suite) + { + var ctx = host?.Context; + if (ctx == null || suite == null || suite.sweep == null) return 0; + if (suite.cases == null || suite.cases.Count == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, "[Bench] Suite has no cases — nothing to do."); + return 0; + } + var tech = suite.techniques ?? new TestSuiteAsset.BenchTechniques(); + if (!tech.legacyXatlasSweep && !tech.hierarchicalProbe && !tech.hierarchicalRepack && !tech.stageDSweep) + { + UvtLog.Warn(UvtLog.Category.Benchmark, "[Bench] All techniques disabled in suite.techniques — nothing to do."); + return 0; + } + + // The operator's LODGroup comes back at the end. Working copies on it are put + // back first (AGENTS.md LODGroup-lifecycle invariant): a Refresh with repacked + // meshes still assigned would adopt them as the baseline. + var origLodGroup = ctx.LodGroup; + if (origLodGroup != null) host.ResetWorkingCopies(); + + string runStamp = SweepRunner.Stamp(); + string benchRunDir = Path.Combine(SweepRunner.ReportsRoot(), $"bench_{runStamp}"); + Directory.CreateDirectory(benchRunDir); + + int caseCount = suite.cases.Count; + int doneCases = 0; + bool cancelled = false; + UvProgress.Begin($"Benchmark ({caseCount} models)", cancelable: true); + try + { + for (int ci = 0; ci < caseCount; ci++) + { + if (UvProgress.CancelRequested) { cancelled = true; break; } + var tc = suite.cases[ci]; + if (tc == null || tc.fbxAsset == null) { UvtLog.Warn(UvtLog.Category.Benchmark, $"[Bench] Case {ci}: null FBX, skipping."); continue; } + string fbxPath = AssetDatabase.GetAssetPath(tc.fbxAsset); + if (string.IsNullOrEmpty(fbxPath)) { UvtLog.Warn(UvtLog.Category.Benchmark, $"[Bench] Case {ci} '{tc.label}': asset has no project path, skipping."); continue; } + var prefabRoot = AssetDatabase.LoadAssetAtPath(fbxPath); + if (prefabRoot == null) { UvtLog.Warn(UvtLog.Category.Benchmark, $"[Bench] Case {ci} '{tc.label}': '{fbxPath}' is not a GameObject prefab, skipping."); continue; } + + UvProgress.Report((float)ci / caseCount, $"case {ci + 1}/{caseCount}: {tc.label} ({Path.GetFileName(fbxPath)})"); + + GameObject spawned = null; + try + { + spawned = (GameObject)PrefabUtility.InstantiatePrefab(prefabRoot); + if (spawned == null) { UvtLog.Error(UvtLog.Category.Benchmark, $"[Bench] Case {ci} '{tc.label}': InstantiatePrefab returned null, skipping."); continue; } + spawned.name = $"[Bench] {tc.label}"; + // A transient spawn must not dirty the scene or survive a save. + spawned.hideFlags = HideFlags.DontSave; + + var lg = FindLodGroup(spawned, tc.lodGroupPath); + if (lg == null) { UvtLog.Warn(UvtLog.Category.Benchmark, $"[Bench] Case {ci} '{tc.label}': no LODGroup found under '{fbxPath}', skipping."); continue; } + host.Bind(lg); + + // Case index prefix keeps two labels that sanitize alike apart. + string safeLabel = SweepRunner.SanitizeForPath(string.IsNullOrEmpty(tc.label) ? lg.name : tc.label); + string caseDir = Path.Combine(benchRunDir, $"{ci:D2}_{safeLabel}"); + Directory.CreateDirectory(caseDir); + if (RunTechniques(host, lg, tech, suite.sweep, caseDir, ci, tc.label)) doneCases++; + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, $"[Bench] Case {ci} '{tc.label}' threw: {ex.Message}"); + } + finally + { + if (spawned != null) + { + // Put the working copies back and unbind BEFORE the spawn goes, or + // the last cell's temporary meshes leak (they are clones, not + // children of the spawn). IsChildOf covers a LODGroup on a + // descendant and on the root itself. + if (ctx.LodGroup != null && ctx.LodGroup.transform.IsChildOf(spawned.transform)) + { + host.ResetWorkingCopies(); + host.Bind(null); + } + UnityEngine.Object.DestroyImmediate(spawned); + } + } + if (UvProgress.CancelRequested) { cancelled = true; break; } + } + } + finally + { + if (cancelled) UvProgress.Cancel(); else UvProgress.End(); + host.Bind(origLodGroup); + string techList = string.Join("+", new[] + { + tech.legacyXatlasSweep ? "legacy" : null, tech.hierarchicalProbe ? "probe" : null, + tech.hierarchicalRepack ? "repack" : null, tech.stageDSweep ? "stageDsweep" : null, + }.Where(s => s != null)); + UvtLog.Info(UvtLog.Category.Benchmark, + $"[Bench] complete: {doneCases}/{caseCount} cases [{techList}]" + (cancelled ? " (cancelled)" : "") + + $". Per-case dirs: BenchmarkReports/bench_{runStamp}/_