diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9ddf6b28 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cmake.ignoreCMakeListsMissing": true +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b4a878..5fb58e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Refactored - `LightmapTransferTool.ExportVertexColorsToFbxCore` now accepts an optional `outputFbxPathOverride` and returns `bool` for success. When the override is set and differs from the source path, Phase 1 (source importer mutation), Phase 4 scene relink, and Phase 5 restore are skipped so the source FBX and live scene stay untouched. Existing overwrite and hierarchy-mode callers keep their void-style usage. +## [1.0.5] - 2026-05-13 + +### Added +- **Pre-pack shell snap to integer atlas pixels** (`SnapShellsToIntegerPixels`, default ON). Before handing shells to xatlas, each shell is scaled per-axis around its UV centroid so its bbox extent is an integer number of atlas texels. Makes xatlas's own per-chart `ceil(extents)` rescale (xatlas.cpp ~line 8345, upstream Issue #18 wontfix) a no-op for every chart, so the uniform per-shell density set up by `TexelDensityNormalizer` survives the pack instead of being amplified by sub-pixel rounding. **No xatlas fork required** — the package builds stock xatlas master via `FetchContent`. +- Auto-resolution mode for Repack (`ResolutionMode.AutoFromTexelDensity`) — pick a target texels-per-meter density and the tool computes the atlas resolution from total 3D area. Manual mode unchanged. Sweep automatically forces Manual so each cell's `atlasResolutions[i]` is the resolution xatlas actually packs at. +- `[Density]` / `[Density:snap]` / `[Density:postUV2]` diagnostic logs at multiple pipeline checkpoints (snap / postAssign / postOrphan / postBorder / postCorrection / final) so per-shell density drift is visible per stage. +- Cancellable xatlas pack — `xatlasPackCharts` now runs on a background `Task` while the main thread polls `DisplayCancelableProgressBar`. xatlas itself has no native cancel API, so cancel = wait for in-flight pack to finish, then discard result and stop pipeline. +- Pack cost-budget preflight — refuses packs that would take many minutes (brute-force budget 500M ops, heuristic 20B). Auto-disables brute force above the budget instead of hanging the editor. +- UI toggles in Pre-pack panel: `Snap shells to integer atlas pixels (pre-pack)`, `Post-pack density correction (experimental)`, `Internal pack oversample` popup. + +### Fixed +- Per-shell lightmap texel density variance on real artist UVs (target ~1× from ~14× spread). Achieved purely via input preparation — no xatlas patch required. +- Sweep `atlasResolutions` dimension was collapsed to a single value when `RepackResolutionMode == AutoFromTexelDensity` (every cell recomputed and overrode the swept value). Sweep now snapshots and forces Manual for the duration. +- `BenchmarkRecorder` `atlasRes` column now reflects the resolution xatlas actually packed at (post auto-compute), not the raw `ctx.AtlasResolution` UI setting. +- Benchmark per-mesh records now skip entries with `include == false` so user-deselected meshes carrying stale `TransferResult` / `ValidationReport` don't surface as failed rows in sweep aggregates. +- `BenchmarkSweep` `totalMs` no longer double-counts inner stages — `pipelineMs` is the outermost wall clock and already contains repack + transfer + validate; summing all four triple-counted inner work. Standalone Repack/Transfer rows (no pipeline wrapper) fall back to the sum of inner stages. + +### Changed +- `TexelDensityNormalizer` now logs a rich `[Density] pre … post … scale …` summary at Info level instead of a terse "rescaled N/N shells" line. +- `Native~/third_party/xatlas/` removed; `Native~/CMakeLists.txt` reverted to `FetchContent_Declare(xatlas)` against upstream master. + ## [1.0.0] - 2026-04-20 ### Changed (breaking) diff --git a/Documentation~/EMBREE_INTEGRATION_PLAN.md b/Documentation~/EMBREE_INTEGRATION_PLAN.md new file mode 100644 index 00000000..d80ec278 --- /dev/null +++ b/Documentation~/EMBREE_INTEGRATION_PLAN.md @@ -0,0 +1,270 @@ +# Embree Integration Plan + +Plan for adding [Intel Embree 4](https://github.com/RenderKit/embree) as an +optional native acceleration backend for transfer, AO baking, and future SDF +generation. Branch: `claude/add-embree-sdf-PwoX3`. + +## Why + +Three existing pipelines need fast spatial queries on triangle meshes: + +1. **Transfer** (`TransferData.cs`, `GroupedShellTransfer.cs`) — closest-point + from LOD_N vertices onto LOD_0 surface, plus barycentric interpolation of + UV2 / vertex attributes. Currently uses managed `TriangleBvh.cs` (512 LoC). +2. **Vertex AO** (`VertexAOBaker.Cpu.cs`, `.Gpu.cs`) — hemisphere ray casts per + vertex against scene geometry. CPU path is single-threaded managed BVH; + GPU path is a DX11/Vulkan ComputeShader. +3. **SDF generation** (planned) — signed-distance field grids for + collision/voxelization workflows. + +All three are textbook Embree use-cases. Embree gives 3–10× over managed BVH +for AO (SIMD ray packets + mature SAH BVH) and removes the GPU dependency for +users without a capable Compute backend. + +## Scope + +- **Optional** native backend, gated by `UNITYMESHLAB_EMBREE` scripting define. +- Without the binary, the package builds and runs exactly as today + (managed `TriangleBvh` + `VertexAOBaker.Cpu`/`.Gpu` fallbacks). +- **Platforms (initial):** Windows x64, Linux x64. +- **Platforms (deferred):** macOS (Apple Silicon arm64). No dev hardware + available — fallback to managed BVH + existing Metal-transpiled GPU AO. + +## Non-goals + +- Replacing the managed `TriangleBvh` outright. It stays as the default + fallback and the source of truth for behaviour. +- Wrapping every Embree feature. We expose only point-query, occlusion-rays, + and SDF-grid entry points. +- Building Embree from source inside Unity. Native lib is built separately + via `Native~/Embree/build_embree.{bat,sh}` and the result lives in + `Plugins/x86_64/`. + +## Architecture + +### Native artifact + +Separate shared library **`umlab-embree`**, NOT merged with the existing +`xatlas-unity` plugin. Embree + TBB add ~30–50 MB and many users won't +need the Embree backend. + +``` +Plugins/x86_64/ + xatlas-unity.dll (existing) + xatlas-unity.so (existing) + umlab-embree.dll (new) + umlab-embree.so (new) + tbb12.dll (new, runtime dep of Embree) + libtbb.so.12 (new) +``` + +### Source layout + +``` +Native~/ + CMakeLists.txt (existing, untouched — xatlas-unity) + xatlas-unity-bridge.cpp (existing) + src/collision.cpp (existing) + third_party/ (existing — VHACD) + Embree/ (NEW) + CMakeLists.txt FetchContent embree v4.3+, statically link + where possible, copy DLL/so to Plugins/x86_64/ + umlab-embree.h C ABI header + umlab-embree.cpp C ABI implementation + build_embree.bat Win64 release build helper + build_embree.sh Linux x64 release build helper + third_party/ + LICENSE-EMBREE.txt Apache 2.0 attribution + LICENSE-TBB.txt Apache 2.0 attribution +``` + +### C# layout + +``` +Editor/ + Native/ (NEW folder — colocated P/Invoke wrappers) + EmbreeNative.cs [DllImport("umlab-embree")] declarations + EmbreeScene.cs IDisposable handle wrapper, cache by mesh id + IBvhBackend.cs Common interface (closest-point, raycast) + TriangleBvh.cs (existing) → implements IBvhBackend + EmbreeBvh.cs (NEW) → implements IBvhBackend + BvhBackendFactory.cs (NEW) Selects Embree if available, else managed + VertexAOBaker.cs (existing) Add Backend.Embree enum value + VertexAOBaker.Embree.cs (NEW) Sibling of .Cpu / .Gpu +``` + +### Define management + +Extend `Editor/PostprocessorDefineManager.cs`: + +- On editor load, check for presence of `Plugins/x86_64/umlab-embree.dll` (Win) + or `Plugins/x86_64/umlab-embree.so` (Linux). +- If present: add `UNITYMESHLAB_EMBREE` to `PlayerSettings` scripting defines + (Editor platform group). If absent: remove it. +- Mirrors the existing pattern for `LIGHTMAP_UV_TOOL_FBX_EXPORTER`. + +## C ABI surface + +Minimal, batched, handle-based. Single P/Invoke per thousands of points to +avoid marshalling overhead. + +```c +// ── Scene lifecycle ── +typedef struct EmbreeSceneOpaque* uml_embree_scene_t; + +uml_embree_scene_t uml_embree_scene_create(void); +void uml_embree_scene_destroy(uml_embree_scene_t); + +// Returns geom_id (>=0) on success, -1 on failure. +int uml_embree_scene_add_mesh( + uml_embree_scene_t scene, + const float* vertices, int vertex_count, // xyz, tightly packed + const int* triangles, int tri_count); // i0,i1,i2 per tri + +void uml_embree_scene_commit(uml_embree_scene_t); // builds BVH + +// ── Transfer: closest-point batch ── +// For each query point, finds closest point on any committed mesh. +void uml_embree_closest_points( + uml_embree_scene_t scene, + const float* points, int point_count, // input: xyz per point + float* out_positions, // xyz, hit position + float* out_normals, // xyz, surface normal + int* out_geom_ids, // -1 if no hit + int* out_prim_ids, + float* out_barycentrics); // u, v per hit (w = 1-u-v) + +// ── AO bake batch ── +// Cosine-weighted hemisphere sampling, occlusion test against the scene. +// Returns ao in [0,1] where 1 = fully unoccluded. +void uml_embree_ao_bake( + uml_embree_scene_t scene, + const float* points, const float* normals, int point_count, + int samples_per_point, + float max_distance, + unsigned int rng_seed, + float* out_ao); + +// ── SDF grid (deferred to PR6) ── +void uml_embree_sdf_grid( + uml_embree_scene_t scene, + const float* origin, const float* cell_size, + int nx, int ny, int nz, + float* out_sdf); // signed distance per cell +``` + +All functions are thread-safe to call sequentially; Embree itself parallelizes +the batch internally via TBB. Do NOT wrap calls in Unity Job System — would +cause TBB/Job pool contention. + +## Integration points + +| File | Change | +|---------------------------------|-----------------------------------------------------------------------------------------------------------------| +| `TriangleBvh.cs` | Extract `IBvhBackend` interface (closest-point + raycast). Existing class implements it. No behaviour change. | +| `TransferData.cs` | Take `IBvhBackend` from factory instead of `new TriangleBvh(...)` directly. | +| `GroupedShellTransfer.cs` | Same pattern. | +| `CoverageSplitSolver.cs` | Same pattern. | +| `VertexAOBaker.cs` | Add `Backend.Embree` to enum. Auto-select if `UNITYMESHLAB_EMBREE` and CPU path requested and scene non-empty. | +| `VertexAOBaker.Embree.cs` | New file: builds Embree scene from input mesh + occluders, calls `uml_embree_ao_bake`, fills vertex AO array. | +| `PostprocessorDefineManager.cs` | Add Embree binary detection alongside existing FBX exporter detection. | + +`UvTransferPipeline.cs` — no changes, already abstracted through `TransferData`. + +## Performance & caching + +- **BVH build cost**: 50–500 ms for typical game meshes. Cache per + `(Mesh.GetInstanceID(), Mesh.vertexCount, Mesh.triangles.Length)` tuple in + `EmbreeScene` static dictionary. Drop on `AssemblyReloadEvents`. +- **Threading**: Embree handles parallelism. C# side issues one P/Invoke per + batch of N points; do NOT slice into Jobs. +- **Memory**: `rtcReleaseScene` on dispose. EditorWindow `OnDisable` must + flush the cache for any scenes it owns. + +## Risks & gotchas + +- **TBB runtime dependency**: Embree links TBB dynamically by default. + Either statically link TBB inside `umlab-embree.dll` (preferred, single + artifact) or ship `tbb12.dll` / `libtbb.so.12` next to it. CMake decision + in PR1. +- **CRT mismatch (Windows)**: Embree precompiled binaries use MSVC dynamic + CRT. Build `umlab-embree.dll` with the same CRT, otherwise heap-cross-DLL + crashes. Pin to `/MD` in CMake. +- **glibc baseline (Linux)**: Build on Ubuntu 20.04 image to keep glibc 2.31 + compatibility. Newer images produce binaries that fail on older Steam + Runtime / CentOS targets. +- **macOS not supported**: `Native~/Embree/CMakeLists.txt` will + `message(FATAL_ERROR)` on `APPLE` to prevent accidental broken builds. + Mac users get the managed fallback automatically (define is not set). +- **License**: Embree (Apache 2.0) and TBB (Apache 2.0) both require + attribution. Add `LICENSE-EMBREE.txt` and `LICENSE-TBB.txt` to + `Native~/Embree/third_party/`. Mention in `CHANGELOG.md` on first release. +- **Plugin import settings**: `umlab-embree.dll` and `umlab-embree.so` need + `.meta` files marking them as Editor-only x86_64. Pattern matches the + existing `xatlas-unity` `.meta` files. + +## Rollout (small PRs) + +### PR1 — Native skeleton + CI +- Add `Native~/Embree/` with CMake, empty C ABI stubs, `build_embree.{bat,sh}`. +- GitHub Actions matrix: `windows-latest`, `ubuntu-20.04`. Build artifact, + do NOT commit binaries (built in CI, attached to release). +- No C# changes. Verify compile only. +- Attribution files in place. + +### PR2 — P/Invoke wrappers + smoke test +- `EmbreeNative.cs`, `EmbreeScene.cs`. +- One EditMode test: build Embree scene from a unit cube, query closest-point + from `(2, 0, 0)`, expect hit at `(0.5, 0, 0)`. +- Test gated by `UNITYMESHLAB_EMBREE`; CI sets the define after building the + binary. Test is a no-op on Mac and on PRs that don't touch native. + +### PR3 — `IBvhBackend` refactor +- Extract interface, refactor existing callers (`TransferData`, + `GroupedShellTransfer`, `CoverageSplitSolver`) to use factory. +- Pure refactor: no Embree implementation yet. Existing managed path is the + only backend. Tests must pass unchanged. + +### PR4 — Embree transfer backend +- Implement `EmbreeBvh : IBvhBackend`. +- Factory returns Embree if `UNITYMESHLAB_EMBREE` is set, else managed. +- Benchmark harness: 100k closest-point queries on a 200k-tri mesh. + Numbers in PR description (managed vs Embree, CI machine). + +### PR5 — Embree AO backend +- `VertexAOBaker.Embree.cs`. Hook into `Backend.Embree` enum. +- Benchmark: 50k vertices × 64 samples on a 100k-tri scene. + Compare `.Cpu`, `.Gpu`, `.Embree`. Numbers in PR description. + +### PR6 — SDF generator (separate effort) +- Standalone tool using `uml_embree_sdf_grid`. +- New `IUvTool` implementation in `Editor/Tools/SdfGeneratorTool.cs`. +- Out of scope for this plan beyond the C ABI hook. + +### Future — macOS +- Add `macos-latest` to CI matrix. +- Build universal2 (arm64 + x86_64) Embree with `EMBREE_ISA_NEON2X=ON`. +- Drop `.dylib` to `Plugins/macOS/`. +- Extend `PostprocessorDefineManager` detect to cover macOS path. +- Requires Apple hardware for testing. Deferred until available. + +## Acceptance per PR + +Each PR must: +- Build cleanly on Windows and Linux CI. +- Not regress any existing test. +- Not change behaviour when `UNITYMESHLAB_EMBREE` is unset (verified by + running tests with the define stripped). +- Include before/after benchmark numbers for PR4 and PR5. + +## Open questions + +1. Static vs dynamic TBB link — decide in PR1 after measuring resulting + `umlab-embree.dll` size with each option. +2. Where to host prebuilt binaries: GitHub Releases attached to package + tags, or a separate `umlab-embree-binaries` repo. Affects how end users + install the package via UPM. +3. Should the Embree AO backend support multi-mesh scenes (occluders from + neighbouring meshes) in PR5, or single-mesh only and defer multi-mesh to + a follow-up? Current `.Gpu` baker is single-mesh; matching that is + simplest. diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 744369c6..f317e5c2 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -310,3 +310,91 @@ 1. Прогнать `Tools → Mesh Lab → Validators → Check Imported MeshLab Artifacts` на корне. 2. Если warning — пересохранить из MeshLab с правильными настройками. 3. Если повторяется — отключить проблемный filter и переэкспортировать. + +## Эксперимент 2026-05-13 — Pre-pack snap к integer atlas pixels (отклонено) + +**Гипотеза:** xatlas `PackCharts` применяет unconditional per-chart `ceil(extents)` rescale (xatlas.cpp:8345-8362) — sub-pixel-thin шеллы амплифицируются и ломают uniform density. Если pre-snap'ить UV extents per-shell к integer pixel grid до xatlas, ceil() становится no-op'ом и density сохраняется без форка xatlas. + +**Что попробовали (5 коммитов, все ревёрнуты):** +1. `SnapShellsToIntegerPixels(uvFlat, shells, tpu)` — per-shell scale вокруг центроида к ceil(extent×tpu)/tpu. +2. tpu = `effectiveTpu = internalRes × sqrt(coverage)`. +3. Добавление `sqrt(s/p)` фактора (ошибочно — для UvMesh xatlas.cpp:8255 хардкодит `surfaceArea = parametricArea`, т.е. `s/p = 1`). +4. Force `rotateCharts = 0` чтобы snap-grid не сбивалась PCA-вращением. +5. Force `resolution = 0` чтобы избежать xatlas's `maxChartSize` clamp (xatlas.cpp:8363-8385). +6. Перестановка `snap` после `PerturbOverlapShellsUv0` (perturb рескейлил шеллы вокруг чужих центроидов и сбивал snap). + +**Результаты на Carousel (149 шеллов, 5 overlap групп, max 92 в одной):** + +| Этап | `postAssign maxRatio` (density) | `postCorrection maxRatio` | +|---|---|---| +| Без snap (только Normalize + PostPackCorrection) | ~14× | ~2.95× | +| Snap к tpu (исходник) | 23.11× | 3.76× | +| + sqrt(s/p) factor (wrong для UvMesh) | 23.11× | 4.19× | +| + resolution=0, rotateCharts=0 | 23.11× | 4.06× | +| + snap после perturb (вместо до) | 20.22× | 4.06× | + +**Вывод:** snap не работает и делает чуть хуже. Причины математически: +- Для anisotropic shell после snap (sx ≠ sy) xatlas's per-chart scale = `1/sqrt(sx × sy)` (потому что parametricArea меняется на `sx × sy`) — частично откатывает наш snap. +- post-xatlas pixel extent для оси x = `original_x × sqrt(sx/sy)` ≠ integer. +- Isotropic snap (sx = sy = s) полностью undone xatlas (scale = 1/s). +- Единственный способ полностью устранить Stage B amplification — форк xatlas или собственный rect-packer. + +**Что оставлено:** `TexelDensityNormalizer.Normalize` (uniform au/a3 = const в UV0) + `PostPackDensityCorrection` (shrink-only post-xatlas). Дает стабильный ~3× density spread на Carousel — лучший достижимый без форка/собственного pack'а. + +**Файлы удалены/откачены:** `SnapShellsToIntegerPixels`, `ComputeEffectiveTpu` в `XatlasRepack.cs`, `RepackOptions.snapShellsToIntegerPixels`, `UvToolContext.SnapShellsToIntegerPixels`, UI toggle, force `rotateCharts=0`/`resolution=0` ветки в pack call. + +**Что НЕ пробовать снова:** +- Per-shell snap к integer pixel grid в любом виде — xatlas's per-chart scale rebuild параметрической площади после snap всё равно ломает grid. +- "Pre-multiply UV by F в pixel space" — эквивалентно snap по математике (xatlas в обоих случаях пересчитает `sqrt(s/p) × tpu` от новой UV-area и обнулит наш масштаб). +- Передача `texelsPerUnit > 0` с `resolution > 0` — триггерит maxChartSize clamp. + +**Что МОЖЕТ помочь (не пробовали):** +- Передавать xatlas **готовый layout** через `xatlasAddMesh` с явными chart definitions, обходя его pack-stage rescale целиком. +- Свой 2D rect-packer на C# поверх Normalize-выровненных шеллов. + +## Эксперимент 2026-05-13 — Density spread 14× → 1.17× без форка (commit a218a2b) + +**Контекст:** после отката pre-pack snap'а (~5 коммитов) вернулись к чистому `Normalize + xatlas + PostPackCorrection`. Density spread держался на ~3× postCorrection, ~14× pre-correction. Цель — закрыть его не форком. + +**Что сработало (в комбинации):** +1. **`UvToolContext.InternalOversample = 1 → 4`** (плюс `RepackOptions.Default.internalOversample = 4`). xatlas pack runs at 1024×1024 internal для пользовательских 256. Sub-pixel шеллы исчезают: shell с UV-extent 0.001 при tpu=909 даёт ≈0.91 px = amp 1.1×, не 4×. +2. **`rotateChartsToAxis = false`** в `RepackOptions.Default`. PCA-rotation перед extents сжимала тонкие шеллы и усиливала `ceil()` amp. Для repack existing UVs (UvMesh путь) она бесполезна. `rotateCharts = true` (90° placement) остаётся. +3. **Удаление `PerturbOverlapShellsUv0`** из обоих pack путей. Это был **главный** убийца: для AddUvMesh xatlas НЕ дедупит charts по UV-сходству — он сегментирует faces по `faceMaterial` (= `shellID`) плюс colocal-UV walk через `vertexToChartMap` (xatlas.cpp:6228-6275). Distinct shellID всегда → distinct charts. Perturb scale `1 + g × strength` cumulative по индексу в группе раздувал sumUV в **~97×** на overlap-группе из 92 шеллов (Carousel), что обрушивало xatlas auto-tpu со 256 до ~104 и кидало все шеллы в sub-pixel regime. +4. **Диагностика `[DensityRisk:prePack]`** в `XatlasRepack.LogStageBRisk` — предсказывает per-shell Stage B amplification (`ceil(extent_px)/extent_px` per axis × per axis) до xatlas. Использует тот же `tpu = sqrt(internalRes² × 0.75 / sumUv)` что xatlas считает внутри. Логирует `subPixel count`, `boost>1.5× count`, `boost>3× count`, top-5 worst шеллов. Точная корреляция: predicted `areaBoost=1.34×` ↔ фактический `postAssign maxRatio=1.27×`. + +**Результат на Carousel (149 шеллов, 5 overlap групп, max group 92):** + +| Метрика | До | После | +|---|---|---| +| Sub-pixel шеллы | 38/149 | 0/149 | +| `[postAssign] maxRatio` | 20-23× | **1.27×** | +| `[postCorrection] maxRatio` | 3-4× | **1.17×** | +| Шеллов внутри ±10% mean | 10/149 | **149/149** | +| Atlas utilization | 28-34% | **55%** | + +**Не пробовать снова:** +- `PerturbOverlapShellsUv0` для UvMesh пути — xatlas не дедупит, perturb только ломает sumUV. +- Per-shell pre-pack snap — xatlas пересчитает per-chart scale от изменённой parametricArea и отменит snap (см. предыдущий эксперимент). +- `rotateChartsToAxis = true` для repack existing UVs — мутирует extents. + +## Эксперимент 2026-05-13 — Oversample heuristic pack + atlas-scaled UV2 tolerances + +**Контекст:** после `a218a2b` default `internalOversample = 4` сохранил density spread, но поднял внутренний xatlas pack с 256² до 1024². Старый preflight отключал brute force только по `shellCount × internalRes² > 500M`; Carousel-кейс 149 × 1024² ≈ 156M оставался ниже budget, хотя wall-time стал ощутимо хуже. В transfer path часть UV2 tolerances оставалась в normalized-space константах (`0.005`, `0.01`), что при resolved atlas 1389×1360 превращало ~1.3px старого допуска в ~6.8px. + +**Изменение 1 (repack):** +- `XatlasRepack.ResolvePackBruteForce()` теперь отключает native `bruteForce` при `internalOversample > 1`, даже если stored UI preference включён. +- Старый safety budget остаётся для `internalOversample = 1`; heuristic safety budget по-прежнему запрещает огромные packs. +- UI делает `Brute force pack` недоступным при oversample выше 1× и явно показывает effective packer = heuristic. + +**Изменение 2 (transfer):** +- `RepackResult.atlasWidth/atlasHeight` сохраняются в `MeshEntry.repackedAtlasWidth/repackedAtlasHeight`. +- `GroupedShellTransfer.Transfer()` принимает resolved source atlas size и переводит UV2 pixel margins через `pixels / min(atlasW, atlasH)`. +- Legacy fallback остаётся прежним (`0.005`, `0.01`) для source meshes с existing UV2 или неизвестным atlas size. +- Full pipeline теперь явно пропускает transfer/auto-tune, если нет включённых target LOD meshes, вместо трёх source-only repack попыток с `coverage=0%`. + +**Проверка:** +- EditMode red/green: `PackPreflight_DisablesBruteForce_WhenInternalOversampleIsAboveOne`. +- EditMode red/green: `BruteForceOption_IsUnavailable_WhenInternalOversampleIsAboveOne`. +- EditMode red/green: `TransferTargetDetection_IgnoresSourceOnlySelection`. +- EditMode red/green: `Uv2PixelMargin_ScalesFromResolvedAtlasSize`. +- Full model benchmark (Carousel/Playground/WateringCan) в этом checkout не прогнан: тестовые FBX/`BenchmarkReports/` отсутствуют в репозитории. Нужен ручной Unity прогон на suite для финального сравнения `repackMs`, `density spread`, `overlapShellPairs`, `invertedCount`, `texelDensityBadCount`. diff --git a/Documentation~/SWEEP_ANALYSIS_2026-04-19.md b/Documentation~/SWEEP_ANALYSIS_2026-04-19.md new file mode 100644 index 00000000..2128ceda --- /dev/null +++ b/Documentation~/SWEEP_ANALYSIS_2026-04-19.md @@ -0,0 +1,149 @@ +# Sweep analysis — LegacyFixed, 2026-04-19 + +**Source data:** `_results/BenchmarkReports/` on branch `results/legacyfixed-2026-04-19`. +60 sweep cells × (`Playground`, `Gazebo`, `Carousel`, `Wooden_Box_Long`) × +(`res ∈ {256, 512, 2048}`, `pad ∈ {2, 4, 6, 8, 32}`, `bdr=0`), one run. +Mode: `LegacyFixed`, `RepackPerMesh=on`, `splitTargets=on`. + +## Health check — pipeline itself + +| Metric | Value | Verdict | +| --- | --- | --- | +| `shellsRejected` | **0** across every cell | clean | +| `overlapShellPairs` (diff-src) | **0** across every cell | clean | +| `coverage` (`verticesTransferred / verticesTotal`) | **1.00** across every cell | clean | +| `overlapTriangleCount` | 0 | clean | + +Pipeline is **solid** — every vertex is transferred, no shells rejected, no +cross-source overlap on any LOD. All variance lives in validation metrics +below. + +## Aggregated defect trends + +`defectScore = stretchedCount + zeroAreaCount + oobCount` — summed across +target LODs of all four models per cell. + +### defectScore pivot (lower = better) + +| pad \ res | 256 | 512 | 2048 | +| --- | --- | --- | --- | +| **2** | 1225 | 1240 | 1225 | +| **4** | 1233 | 1171 | 1253 | +| **6** | 1092 | 1235 | 1216 | +| **8** | 1092 | 1218 | 1255 | +| **32** | **864** | **989** | 1209 | + +Big `pad=32` wins on 256/512 for quality; on 2048 the effect flattens. + +### avg repackMs pivot + +| pad \ res | 256 | 512 | 2048 | +| --- | --- | --- | --- | +| 2 | 40 | 58 | 123 | +| 4 | 43 | 51 | 150 | +| 6 | 45 | 57 | 180 | +| 8 | 51 | 64 | 216 | +| **32** | **312** | **408** | **1157** | + +`pad=32` is 8–10× slower than `pad=2` at the same resolution. The +quality win is only worth that cost on 256/512. + +### avg texelDensityMedian pivot (lower = tighter UV2) + +| pad \ res | 256 | 512 | 2048 | +| --- | --- | --- | --- | +| 2 | 106 | 94 | **72** | +| 4 | 161 | 114 | 82 | +| 6 | 216 | 152 | 99 | +| 8 | 283 | 161 | 89 | +| 32 | 1228 | 616 | 180 | + +Rough rule: doubling `res` ≈ halves texel density; doubling `pad` ≈ +doubles it. `res=2048, pad=2` is the tightest UV2. + +## Per-model highlights + +**Gazebo** — cleanest. Best cell `res=256, pad=2`, defectScore=15 across 2 +target LODs. 25 mirror pairs detected in UV0, SymSplit handled them +fine. + +**Wooden_Box_Long** — also clean, defectScore 14–20. `uv0AabbOverlapPairs=1631` +on a 991-vert mesh is very high (dense tiling in UV0), but the pipeline +still produces no diff-src overlaps. Good regression test for SymSplit. + +**Playground** — largest model (10k verts × 3 LODs), defectScore 83–88 at +best. stretched+zeroArea are tiny (~85), but **`invertedCount` is +5000–8000**. Per per-LOD drill: LOD1 has 3000–5000 inverted, LOD2 has +1500–2500, LOD3 has 300–400. Per `TransferValidator` docstring, winding +flip is expected (UV0 vs UV2 independent), but at this scale it's worth +double-checking visually (UV2 PNG dumps are in the sweep `_png/` +folders). + +**Carousel** — hardest. defectScore 741–965 (of ~3000 triangles = +**25–32% defective**). Breakdown per LOD at `res=512, pad=2`: + +| LOD | inverted | stretched | zeroArea | topologyFixed | capHit | +| --- | --- | --- | --- | --- | --- | +| 1 | 876 | 305 | 366 | 6 | 0 | +| 2 | 421 | 194 | 199 | 12 | 1 | +| 3 | 179 | 28 | 10 | **34** | 1 | + +LOD3 has the fewest triangles but the **most aggressive topology +enforcement** (34 Laplacian fixes), and the cap is hit on LOD2/LOD3 for +most cells. Cause is the N-fold rotational SymSplit — after splitting a +rotational pattern into N charts, the fragments don't always land on +clean UV0 boundaries, so `EnforceShellTopologyOnUv2` does a lot of work +on heavily simplified LODs. + +## Topology cap signal + +`topologyCapHit` summed across 60 cells: + +| pad \ res | 256 | 512 | 2048 | +| --- | --- | --- | --- | +| 2 | 4 | 3 | 3 | +| 4 | 2 | 3 | 3 | +| 6 | 2 | 4 | 3 | +| 8 | 3 | 4 | 2 | +| 32 | 3 | 3 | 3 | + +Cap hit rate ~5–10% of cells, consistent across pad. Mostly on Carousel +and Wooden_Box_Long. Worth one follow-up experiment: raise +`kMaxTopologyIterations` from 5 → 8, rerun Carousel sweep, see if +stretched/zeroArea drop. + +## Recommendations + +Immediate defaults (for `MeshLabProjectSettings`): +- **`atlasResolution = 512`**: best balance. `2048` gives ~60ms extra + repack for modest quality gain; `256` is fine for tiny assets only. +- **`shellPaddingPx = 4`**: near-minimum repack cost (~50ms), defectScore + mid-pack. `pad=32` wins on 256/512 defect count but pays 8× repack + cost — only worth it in a final bake. +- **`borderPaddingPx = 0`** kept as default (not swept in this run). + +Follow-ups worth running: +1. **Adaptive vs LegacyFixed** — exactly the same sweep with + `SymSplit thresholds = Adaptive`. Expected signal on + `symSplitFallbackCount` (currently all zero in this LegacyFixed + sweep) and Carousel stretched/zeroArea. +2. **Carousel isolated** — same sweep + topology cap=8, see if Carousel + defects drop materially. +3. **Investigate invertedCount** — inspect a handful of Playground + `_png/` UV2 dumps visually. If the flipped triangles look correct + (i.e. UV2 just has mirrored winding relative to UV0), confirm we + can safely ignore this metric; if not, we have a real bug. +4. **Fill the res=1024 gap** — add 1024 to the sweep matrix so the + `res × pad` surface is fully sampled. +5. **borderPad sweep** — `[0, 2, 4]` once defaults above are confirmed. + +## Reproducing the analysis + +Raw combined data: `_results/BenchmarkReports/` (CSV + JSON + PNG per +cell, plus `FbxMetrics_*/` baselines). One-liner: + +```python +import pandas as pd, glob +sweep = pd.concat(pd.read_csv(f) for f in + glob.glob('_results/BenchmarkReports/*_sweep_*.csv')) +``` diff --git a/Documentation~/TRANSFER_BENCHMARK.md b/Documentation~/TRANSFER_BENCHMARK.md new file mode 100644 index 00000000..9cd0153b --- /dev/null +++ b/Documentation~/TRANSFER_BENCHMARK.md @@ -0,0 +1,243 @@ +# Transfer Modes Benchmark — Protocol & Metrics + +> See `EXPERIMENTS.md` for the experiments log (what we tried and why). +> This doc is about **how to measure** each run reproducibly. + +## Purpose + +As the UV2 transfer pipeline accumulates alternative modes (SymSplit +`LegacyFixed` vs `Adaptive`, `RepackPerMesh`, `splitTargets in SymmetryStep`, +topology iteration caps, free-space relocator, etc.) we need a repeatable way +to: + +1. Run the same model through several mode combinations. +2. Collect machine-readable metrics (CSV + JSON). +3. Inspect remaining defects visually, filtered by category. + +## Pipeline state (2026-05) + +Repack stage (single mesh OR multi-mesh joint atlas): + +1. **Extract shells** + per-face shell IDs (`UvShellExtractor`). +2. **Perturb UV0** of overlap-grouped shells (`PerturbOverlapShellsUv0`) so + xatlas's chart-dedup heuristic gives every tile-instance a distinct atlas + slot. Strength is adaptive from atlas resolution + padding by default, + manual override via `RepackOptions.perturbStrength`. +3. **Pre-pack normalisation** (`TexelDensityNormalizer.Normalize`, two passes): + - *Pass 1 — aspect.* Per-shell PCA on UV0 vertices and on 3D vertices + projected onto the shell's mean tangent plane. Apply non-uniform scale + along the UV principal axes so σ1_UV/σ2_UV matches σ1_3D/σ2_3D, capped + by `maxShellAspect` (default 2). Area-preserving: scale_a1 × scale_a2 = 1. + - *Pass 2 — density.* Re-measure UV area on the now aspect-correct shells, + compute area-weighted (or median) target density, apply uniform per-shell + scale, then a global shrink to `targetUvCoverage` (default 0.75) leaves + packer slack so xatlas doesn't overflow the requested atlas resolution. +4. **xatlas pack** with `bruteForce + rotateCharts` on by default. +5. **UV2 write-back** + orphan-vertex fix. +6. **Atlas utilization log line** for visibility. + +Transfer stage (per target LOD): `GroupedShellTransfer.Transfer` — unchanged +from earlier docs. Matches target shells to source by UV0 bbox + world +centroid + world normal, then applies similarity transform / interpolation / +strip-parameterization. + +### Removed modes + +- **`mergeOverlappingTiles`** (and its helpers: `IsEquivalentShellForTileMerge`, + `BuildNonDuplicate*`, post-process `FixOverlappingUv2Shells`, + `FixNearDuplicateUv2Shells`, `RelocateToFreeSpace`, `RescaleUv2ToUnit`). + This mode collapsed tile-instance shells into one xatlas chart and copied + UV2 from the rep to every duplicate. Wrong for lightmap UV2 — every plank + needs its own lightmap region for correct baked lighting. The fix passes + were band-aids for false overlaps the merge path produced; with merge gone + they were unreachable. Deleted entirely. + +### Open / next + +- Aspect normalisation is approximate for curved surfaces (cylinder unrolled + parameterization isn't AABB-extent-shaped). Acceptable for typical + lightmap meshes (planks, walls, panels). +- Cross-LOD aspect consistency: pass 1 currently re-derives per-mesh; for + multi-LOD groups it may be worth deriving on LOD0 then propagating. +- Iterative shrink-to-fit (auto-tune `targetUvCoverage` to land at exactly + the requested resolution) — proposed, not implemented. + +## Tooling + +| 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. | +| `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 → Lightmap UV Tool → Test Suite`. | + +## Metrics (one CSV row per mesh × LOD) + +Session-level (same across rows of one run): + +- `timestamp`, `runLabel`, `lodGroup`, `symSplitMode`, `repackPerMesh`, `splitTargets` +- `atlasRes`, `shellPad`, `borderPad`, `sourceLod` +- `pipelineMs`, `repackMs`, `transferMs`, `validateMs` — accumulated stage timers. + +Pre/post repack overlap pair counts used to ride here as scalar fields, but +they were sentinel-only (never populated) and were removed. Pre-pack overlap +counts are still visible in the verbose `[xatlas] Pre-repack mesh N: K shells, G overlap groups, P pairs` log line per mesh. + +Per-row (snapshot of `TransferResult` / `ValidationReport` / static counters): + +- `shellsMatched`, `shellsUnmatched`, `shellsTransform`, `shellsInterpolation`, + `shellsMerged`, `shellsRejected`, `shellsOverlapFixed` +- `dedupConflicts`, `fragmentsMerged`, `consistencyCorrected` +- `verticesTransferred`, `verticesTotal` +- `invertedCount`, `stretchedCount`, `zeroAreaCount`, `oobCount`, `cleanCount` +- `overlapShellPairs`, `overlapTriangleCount`, `overlapSameSrcPairs` +- `texelDensityBadCount`, `texelDensityMedian` +- `symSplitFallbackCount`, `symSplitTotalCount` +- `topologyIterations`, `topologyFixed`, `topologyCapHit` + +JSON output mirrors the CSV but nests `records[]` inside a run envelope. + +## Protocol + +1. **Prepare a suite.** + `Assets → Create → Lightmap UV Tool → Test Suite`. Add one `TestCase` per + model; set a short `label` (becomes `runLabel` in CSV), point `fbxAsset` + at the FBX, and list your expected ranges in `expectations` (informational; + not enforced automatically). + +2. **Pick a mode combination.** In `LightmapTransferTool`: + - `SymSplit thresholds` = `LegacyFixed` or `Adaptive` + - `Per-mesh repack` on/off + - `SymSplit target LODs (advanced)` on/off + +3. **Run the pipeline.** Click *Run Full Pipeline*. `BenchmarkRecorder` wraps + the call, writes `/BenchmarkReports/{ts}_{lodGroup}_FullPipeline_{mode}.{csv,json}` + when the run finishes. + +4. **Inspect visually.** Open *Transfer tab → Validation Overlay*. Toggle + `Inverted`, `Stretched`, `ZeroArea`, `OutOfBounds`, `Overlap`, + `TexelDensity` to isolate a category on the UV canvas. `None` selected = + every triangle drawn (original behavior). + +5. **Compare.** Switch the mode combination, hit *Reset Pipeline State* → + *Run Full Pipeline* again. Each run produces a separate CSV — diff with + a spreadsheet / pandas. + +### Parameter sweep (atlasRes × shellPad × borderPad) + +For automated sweeps across repack parameters, fill `TestSuiteAsset.sweep`: + +``` +atlasResolutions = [256, 512, 2048] +shellPaddingPxVariants = [2, 4, 8, 32] +borderPaddingPxVariants = [0] +resetBetweenRuns = true +``` + +In *LightmapTransferTool → Setup tab*, 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: + +1. Sets `ctx.AtlasResolution` / `ShellPaddingPx` / `BorderPaddingPx`. +2. Calls `ResetWorkingCopies()` (no sidecar delete, no FBX reimport — just + restores `originalMesh = fbxMesh` and clears pipeline flags). +3. Runs `ExecFullPipeline("sweep_res{R}_pad{S}_bdr{B}")` — each cell's CSV + + JSON carry the cell identifier in the filename and as the `runLabel` + column. BenchmarkRecorder additionally dumps one PNG per recorded mesh + into a sibling `{fileBase}_png/` folder, showing the result UV2 + (repacked mesh on source LOD, transferred mesh on target LODs) with + per-shell coloring — so visual diffs between cells are immediate. + +Original atlas/padding values are restored when the sweep finishes or is +cancelled. A progress bar with **Cancel** is shown during the sweep. + +Concatenate the output for analysis: + +``` +pandas.concat([pd.read_csv(f) for f in glob('BenchmarkReports/*_sweep_*.csv')]) +``` + +### FBX baseline metrics (run once before a sweep) + +Before running a sweep, export the source-FBX characterization so the sweep +numbers can be interpreted against each model's baseline. + +Menus: +- `Mesh Lab → Export FBX Metrics (Selected Assets)` — select one or more + `.fbx` assets in the Project window, then run. Scans every LODGroup / + Renderer inside each FBX. +- `Mesh Lab → Export FBX Metrics (Scene LODGroup)` — select any GameObject + under a LODGroup in the Hierarchy, then run. Scans that LODGroup only. + +Output goes to `/BenchmarkReports/FbxMetrics_{ts}/`: + +- `FbxMetrics_{ts}.csv` — one row per mesh × LOD with vertex/triangle count, + bounds size, avg edge length, shell count, UV0 coverage, AABB overlap + pairs, OOB verts, estimated mirror pairs, UV2 stats (if present), etc. +- `png/__LOD{N}__uv0.png` — UV0 snapshot with + per-shell coloring + wire + 0–1 bounding box, range `[-0.1, 1.1]` so OOB + verts are visible. +- `png/__LOD{N}__uv2.png` — same for UV2 when + present. + +Share both the FBX metrics CSV and the sweep CSVs when asking for analysis; +joining on `(model, lodGroup, rendererName, lodIndex)` gives context for +each sweep cell (e.g. `postRepackOverlaps=0` on a model with +`uv0AabbOverlapPairs=120` is a much stronger signal than on a model with 2). + +### Log filters + +When a run is noisy (e.g. Adaptive threshold messages spam the console), open +*Pipeline Settings → Log filters* and uncheck the offending `UvtLog.Category`. +Verbosity (`Level`) still controls global threshold; the mask is an additional +silencer persisted per user in EditorPrefs +(`LightmapUvTool_LogCategoryMask`). + +| Category | Typical messages | +| --- | --- | +| `General` | Default bucket for legacy `UvtLog.Info(msg)` calls. | +| `SymSplit` | Symmetry split detection + fallback matches. | +| `Repack` | Atlas repack via xatlas. | +| `Match` | Shell matching / similarity transform. | +| `Dedup` | Source-shell dedup passes. | +| `Overlap` | Post-transfer overlap detection & relocation. | +| `Topology` | Laplacian displaced-vertex pass. | +| `Validation` | `TransferValidator` summaries. | +| `Export` | FBX / sidecar export. | +| `Benchmark` | `BenchmarkRecorder` output paths. | + +## Test matrix (fill in per-run) + +| Model | SymSplit | RepackPerMesh | SplitTargets | Date | Result file | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| Playground | LegacyFixed | off | off | | | baseline | +| Playground | Adaptive | off | off | | | compare fallbackCount, stretched, inverted | +| Playground | LegacyFixed | on | off | | | compare topologyCapHit | +| WateringCan | LegacyFixed | off | off | | | simple symmetric case | +| Carousel | LegacyFixed | off | off | | | rotational symmetry (N-fold) | + +## Go / Stop criteria (suggested thresholds) + +These are rules of thumb — adjust per case in the `TestSuiteAsset` expectations +list. + +- **Inverted faces:** 0. Any non-zero = STOP. +- **Overlap shell pairs (diff-src):** 0 on source LOD. Up to 2 tolerable on + target LODs. +- **Shells rejected:** 0. STOP if non-zero. +- **SymSplit fallbackCount:** <= 1 across all target LODs. Higher = shell + descriptor hashing is unreliable on this model; investigate. +- **Topology cap hit:** false. If true, either increase + `kMaxTopologyIterations` or accept residual displacement. +- **Coverage** (`verticesTransferred / verticesTotal`): >= 0.99. + +## Known models + +- **Playground** — stress test with many separate groups, used across most + experiments. Highly sensitive to fragment-merge behavior. +- **WateringCan** — simple mirror symmetry; canonical SymSplit binary case. +- **Carousel** — N-fold rotational symmetry; exercises `ApplyNFoldSplit`. + +See `EXPERIMENTS.md` for the history of failed approaches on each of these. diff --git a/Editor/ArapParameterization.cs b/Editor/ArapParameterization.cs new file mode 100644 index 00000000..00d0a573 --- /dev/null +++ b/Editor/ArapParameterization.cs @@ -0,0 +1,963 @@ +// ArapParameterization.cs — As-Rigid-As-Possible (ARAP) UV parameterization. +// +// Implements Liu et al. 2008 "A Local/Global Approach to Mesh Parameterization" +// for re-unwrapping individual ribbon-shaped shells whose authored / xatlas-LSCM +// UV0 produces stretched slivers. The algorithm alternates a local SVD-style +// step (find best 2x2 rotation per triangle) with a global Poisson-style step +// (cotangent-Laplacian linear solve for new UV positions). +// +// The cotangent weights can be negative on obtuse triangles which breaks SPD +// of the Laplacian — we clamp negative cotangents to 0 (Mullen et al. fix). +// One vertex is pinned to (0,0) to kill the rigid-translation nullspace; the +// global solve is therefore on the free×free sub-block of L. +// +// Author: SashaRX.UnityMeshLab + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class ArapParameterization + { + // ── Numerical tolerances ──────────────────────────────────────────── + // Triangles with rest-area below this (in 3D units²) are skipped — + // they would produce inf cot weights and corrupt the Laplacian. + const float kDegenerateTriArea = 1e-12f; + // CG relative residual tolerance and max iter cap. + const double kCgRelTol = 1e-6; + const int kCgMaxIter = 500; + // ARAP early-exit: if max |R_T - R_T_prev| (Frobenius) drops below + // this in two consecutive iterations we stop early. + const float kRotationDeltaTol = 1e-5f; + + // ──────────────────────────────────────────────────────────────────── + // Public API + // ──────────────────────────────────────────────────────────────────── + + /// + /// Re-parameterize the UV0 coordinates of a single shell using ARAP + /// (As-Rigid-As-Possible) local-global iterations. Modifies uvFlat in + /// place for vertices in shellVertexIndices. + /// + /// Mesh 3D positions (full mesh). + /// Mesh triangle indices (full mesh, length = 3 × triCount). + /// Indices into globalTris/3 — which triangles belong to this shell. + /// Global vertex indices for this shell. + /// Flat UV0 array, indexed by globalVertexIdx × 2. Modified in place. + /// Number of local-global iterations. 10 is usually sufficient. + /// Out: how many triangles were flipped in the initial UV0 (informational). + /// True if reparameterization converged and modified UVs, false if degenerate (returns uvFlat unmodified). + internal static bool Reparameterize( + Vector3[] positions, + int[] globalTris, + int[] shellTriIndices, + ICollection shellVertexIndices, + float[] uvFlat, + int iterations, + out int initialFlipCount) + { + initialFlipCount = 0; + + if (positions == null || globalTris == null || uvFlat == null || + shellTriIndices == null || shellVertexIndices == null || + shellTriIndices.Length == 0 || shellVertexIndices.Count < 3 || + iterations <= 0) + { + return false; + } + + int triCount = shellTriIndices.Length; + int n = shellVertexIndices.Count; + + // Early-exit on trivial shells. ARAP on a 2-tri / <4-vert patch is + // numerically meaningless (single rotation, two-vertex pin) and on + // ribbon-classified flat plates only burns cycles and risks UV + // corruption — keep the original UV0 untouched. + if (n < 4 || triCount < 2) + { + UvtLog.Verbose(UvtLog.Category.Repack, + $"[ARAP] shell verts={n} tris={triCount} below minimum size — skipped"); + return false; + } + + // ── Build global → local vertex index map ─────────────────────── + // Sorted local-index assignment by global index keeps the + // resulting linear system deterministic across runs. + var localOf = new Dictionary(n); + int[] globalOf; + { + var sorted = new List(shellVertexIndices); + sorted.Sort(); + var globalList = new List(sorted.Count); + for (int si = 0; si < sorted.Count; si++) + { + int gi = sorted[si]; + if (localOf.ContainsKey(gi)) continue; // dedup just in case + localOf[gi] = globalList.Count; + globalList.Add(gi); + } + globalOf = globalList.ToArray(); + n = globalOf.Length; + } + + if (n < 3) return false; + + // ── Per-triangle flatten to 2D + half-edge cot weights ────────── + // tri[t] = (la, lb, lc) with local indices. + // rest2D[t*6..t*6+5] = (xa, ya, xb, yb, xc, yc). + // halfCot[t*3..t*3+2] = half-cot of the angle opposite each edge: + // halfCot[t*3+0] = 0.5 * cot(angle at lc) = weight on edge (la,lb) + // halfCot[t*3+1] = 0.5 * cot(angle at la) = weight on edge (lb,lc) + // halfCot[t*3+2] = 0.5 * cot(angle at lb) = weight on edge (lc,la) + var triLocal = new int[triCount * 3]; + var rest2D = new float[triCount * 6]; + var halfCot = new float[triCount * 3]; + int degenerateTris = 0; // tracked for diagnostics + + int triValidCount = 0; + for (int t = 0; t < triCount; t++) + { + int f = shellTriIndices[t]; + int baseIdx = f * 3; + if ((uint)baseIdx + 2 >= (uint)globalTris.Length) { degenerateTris++; continue; } + int g0 = globalTris[baseIdx + 0]; + int g1 = globalTris[baseIdx + 1]; + int g2 = globalTris[baseIdx + 2]; + if ((uint)g0 >= (uint)positions.Length || + (uint)g1 >= (uint)positions.Length || + (uint)g2 >= (uint)positions.Length) + { degenerateTris++; continue; } + + if (!localOf.TryGetValue(g0, out int l0) || + !localOf.TryGetValue(g1, out int l1) || + !localOf.TryGetValue(g2, out int l2)) + { degenerateTris++; continue; } + + Vector3 p0 = positions[g0]; + Vector3 p1 = positions[g1]; + Vector3 p2 = positions[g2]; + + Vector3 e01 = p1 - p0; + float len01 = e01.magnitude; + if (len01 < 1e-12f) { degenerateTris++; continue; } + Vector3 e01u = e01 / len01; + Vector3 e02 = p2 - p0; + float proj = Vector3.Dot(e02, e01u); + float perpSq = e02.sqrMagnitude - proj * proj; + if (perpSq < 0f) perpSq = 0f; + float perp = Mathf.Sqrt(perpSq); + + // Flat positions: x0 at origin, x1 on +X axis, x2 in upper half-plane. + float xa = 0f, ya = 0f; + float xb = len01, yb = 0f; + float xc = proj, yc = perp; + + // 2 * triangle area in flat space. + float area2 = (xb - xa) * (yc - ya) - (xc - xa) * (yb - ya); + if (Mathf.Abs(area2) < kDegenerateTriArea) { degenerateTris++; continue; } + + int tOut = triValidCount; + triLocal[tOut * 3 + 0] = l0; + triLocal[tOut * 3 + 1] = l1; + triLocal[tOut * 3 + 2] = l2; + rest2D[tOut * 6 + 0] = xa; rest2D[tOut * 6 + 1] = ya; + rest2D[tOut * 6 + 2] = xb; rest2D[tOut * 6 + 3] = yb; + rest2D[tOut * 6 + 4] = xc; rest2D[tOut * 6 + 5] = yc; + + // Cotangent of the angle at each vertex from the flat rest + // triangle. cot(angle at v) = dot(e_a, e_b) / (2 * area) where + // e_a, e_b are the two edges incident at v. Use signed area2 + // so cotangents pick up the correct sign of obtuse angles. + // Edges at vertex a (= x0): a→b = (xb-xa,yb-ya), a→c = (xc-xa,yc-ya) + float ax1 = xb - xa, ay1 = yb - ya; + float ax2 = xc - xa, ay2 = yc - ya; + float dotA = ax1 * ax2 + ay1 * ay2; + float cotA = dotA / area2; + // Vertex b (= x1): b→a, b→c + float bx1 = xa - xb, by1 = ya - yb; + float bx2 = xc - xb, by2 = yc - yb; + float dotB = bx1 * bx2 + by1 * by2; + float cotB = dotB / area2; + // Vertex c (= x2): c→a, c→b + float cx1 = xa - xc, cy1 = ya - yc; + float cx2 = xb - xc, cy2 = yb - yc; + float dotC = cx1 * cx2 + cy1 * cy2; + float cotC = dotC / area2; + + // Clamp negative cot weights to 0 (Mullen et al. "Spectral + // Conformal Parameterization") to keep L SPD on obtuse triangles. + if (cotA < 0f) cotA = 0f; + if (cotB < 0f) cotB = 0f; + if (cotC < 0f) cotC = 0f; + + // Half-cot stored per edge as defined above. + halfCot[tOut * 3 + 0] = 0.5f * cotC; // edge (l0,l1) + halfCot[tOut * 3 + 1] = 0.5f * cotA; // edge (l1,l2) + halfCot[tOut * 3 + 2] = 0.5f * cotB; // edge (l2,l0) + + triValidCount++; + } + + if (triValidCount == 0) + { + return false; + } + + // ── Build cotangent Laplacian as a dictionary-of-rows ─────────── + // diag[i] = Σ_j w_ij ; off[i] = list of (j, w_ij). We then expand + // to CSR for fast CG. w_ij accumulates contributions from each + // incident triangle. + var rowMaps = new Dictionary[n]; + for (int i = 0; i < n; i++) rowMaps[i] = new Dictionary(); + double[] diag = new double[n]; + + for (int t = 0; t < triValidCount; t++) + { + int l0 = triLocal[t * 3 + 0]; + int l1 = triLocal[t * 3 + 1]; + int l2 = triLocal[t * 3 + 2]; + double w01 = halfCot[t * 3 + 0]; + double w12 = halfCot[t * 3 + 1]; + double w20 = halfCot[t * 3 + 2]; + + AccumulateLaplacian(rowMaps, diag, l0, l1, w01); + AccumulateLaplacian(rowMaps, diag, l1, l2, w12); + AccumulateLaplacian(rowMaps, diag, l2, l0, w20); + } + + // ── Determine pinned vertex and free set ──────────────────────── + // Pin one boundary vertex if a boundary loop exists; otherwise + // pin local index 0. Pinning kills the rigid-translation + // nullspace and makes L_FF SPD for the CG solve. + bool hasBoundary; + var boundaryLoop = FindBoundaryLoop(triLocal, triValidCount, n, out hasBoundary); + int pinned = hasBoundary && boundaryLoop.Count > 0 ? boundaryLoop[0] : 0; + + // free → local mapping. freeOfLocal[localIdx] = position in the + // dense free-vector or -1 if pinned. + int nFree = n - 1; + var freeOfLocal = new int[n]; + int fIdx = 0; + for (int i = 0; i < n; i++) + { + if (i == pinned) { freeOfLocal[i] = -1; continue; } + freeOfLocal[i] = fIdx++; + } + + // ── Build CSR of L restricted to free×free ────────────────────── + // For an SPD Laplacian on free DOFs we only need the free×free + // sub-block (the pinned column contribution is zero because we + // pin u[pinned] = (0,0)). diag[freeRow] - includes the full + // diagonal Σ_j w_ij; off-diagonals are weights to other free + // vertices only. + var csrRowPtr = new int[nFree + 1]; + var rowFillCounts = new int[nFree]; + for (int i = 0; i < n; i++) + { + int fi = freeOfLocal[i]; + if (fi < 0) continue; + int count = 0; + foreach (var kv in rowMaps[i]) + { + int fj = freeOfLocal[kv.Key]; + if (fj < 0) continue; + if (kv.Value == 0.0) continue; + count++; + } + rowFillCounts[fi] = count; + } + csrRowPtr[0] = 0; + for (int i = 0; i < nFree; i++) csrRowPtr[i + 1] = csrRowPtr[i] + rowFillCounts[i]; + int nnz = csrRowPtr[nFree]; + var csrCol = new int[nnz]; + var csrVal = new double[nnz]; + var csrDiag = new double[nFree]; + var fillCursor = new int[nFree]; + for (int i = 0; i < n; i++) + { + int fi = freeOfLocal[i]; + if (fi < 0) continue; + csrDiag[fi] = diag[i]; + int baseOff = csrRowPtr[fi]; + int cursor = 0; + foreach (var kv in rowMaps[i]) + { + int fj = freeOfLocal[kv.Key]; + if (fj < 0) continue; + if (kv.Value == 0.0) continue; + csrCol[baseOff + cursor] = fj; + csrVal[baseOff + cursor] = -kv.Value; // off-diagonal sign + cursor++; + } + fillCursor[fi] = cursor; + } + + // ── Build initial UV (centered around shell centroid) ─────────── + // Reads current UV0 from uvFlat for this shell's vertices and + // recentres on its centroid so the local linear system has a + // small-magnitude initial state. + var uLocal = new double[n]; + var vLocal = new double[n]; + double cx = 0.0, cy = 0.0; + for (int i = 0; i < n; i++) + { + int gi = globalOf[i]; + int idx = gi * 2; + if ((uint)idx + 1 >= (uint)uvFlat.Length) + { + return false; + } + uLocal[i] = uvFlat[idx]; + vLocal[i] = uvFlat[idx + 1]; + cx += uLocal[i]; + cy += vLocal[i]; + } + cx /= n; cy /= n; + for (int i = 0; i < n; i++) { uLocal[i] -= cx; vLocal[i] -= cy; } + + // Initial bbox + flip-count diagnostics on the recentred UV. + double minU = double.MaxValue, maxU = double.MinValue; + double minV = double.MaxValue, maxV = double.MinValue; + for (int i = 0; i < n; i++) + { + if (uLocal[i] < minU) minU = uLocal[i]; + if (uLocal[i] > maxU) maxU = uLocal[i]; + if (vLocal[i] < minV) minV = vLocal[i]; + if (vLocal[i] > maxV) maxV = vLocal[i]; + } + double bboxArea = Math.Max(0.0, maxU - minU) * Math.Max(0.0, maxV - minV); + + int flipped = 0; + for (int t = 0; t < triValidCount; t++) + { + int l0 = triLocal[t * 3 + 0]; + int l1 = triLocal[t * 3 + 1]; + int l2 = triLocal[t * 3 + 2]; + double a = (uLocal[l1] - uLocal[l0]) * (vLocal[l2] - vLocal[l0]) + - (uLocal[l2] - uLocal[l0]) * (vLocal[l1] - vLocal[l0]); + if (a < 0) flipped++; + } + initialFlipCount = flipped; + + // Mirror-fix: if every triangle is CW in input UV0 (a common + // authoring choice on symmetric models — the unwrap is mirrored) + // the rest 2D triangles built above are CCW (x̃₂ in upper + // half-plane), so ARAP's local rotation step would have to encode + // a reflection it cannot represent in SO(2). Tutte fallback would + // then discard topology entirely. Cheaper and faithful: flip uLocal + // around 0 (centroid) so every tri becomes CCW, and run ARAP + // normally. Recompute flipped — should drop to 0. + if (flipped == triValidCount && triValidCount > 0) + { + for (int i = 0; i < n; i++) uLocal[i] = -uLocal[i]; + int reflipped = 0; + for (int t = 0; t < triValidCount; t++) + { + int l0 = triLocal[t * 3 + 0]; + int l1 = triLocal[t * 3 + 1]; + int l2 = triLocal[t * 3 + 2]; + double a = (uLocal[l1] - uLocal[l0]) * (vLocal[l2] - vLocal[l0]) + - (uLocal[l2] - uLocal[l0]) * (vLocal[l1] - vLocal[l0]); + if (a < 0) reflipped++; + } + UvtLog.Verbose(UvtLog.Category.Repack, + $"[ARAP] shell verts={n} tris={triValidCount}: input UV is mirrored (all {triValidCount} tris CW) → flip-fix and continue (post-flip flipped={reflipped})"); + flipped = reflipped; + } + + // Fallback: collapsed or majority-flipped initial UV → Tutte embed + // onto the boundary circle. Tutte requires a discoverable boundary + // loop (open shell with a topological boundary). + bool needFallback = (flipped > triValidCount / 2) || (bboxArea < 1e-12); + if (needFallback) + { + if (!hasBoundary || boundaryLoop.Count < 3) + { + // Closed shell or no usable boundary — ARAP cannot proceed. + return false; + } + if (!TutteEmbed(boundaryLoop, rowMaps, diag, n, uLocal, vLocal)) + return false; + // Pin must be a boundary vertex for the Tutte solution to be + // consistent with subsequent ARAP iterations (we pin to (0,0) + // but Tutte pins all boundary verts; we just keep our chosen + // pinned = boundaryLoop[0] which Tutte already placed on the + // unit circle — translate so that pinned vertex sits at (0,0)). + double tx = uLocal[pinned]; + double ty = vLocal[pinned]; + for (int i = 0; i < n; i++) { uLocal[i] -= tx; vLocal[i] -= ty; } + } + else + { + // Standard pin: translate so the pinned vertex sits at (0,0). + double tx = uLocal[pinned]; + double ty = vLocal[pinned]; + for (int i = 0; i < n; i++) { uLocal[i] -= tx; vLocal[i] -= ty; } + } + + // ── ARAP local-global iterations ──────────────────────────────── + // R_T stored as (c, s) per triangle: 2x2 rotation [[c,-s],[s,c]]. + var rotC = new double[triValidCount]; + var rotS = new double[triValidCount]; + var rotCPrev = new double[triValidCount]; + var rotSPrev = new double[triValidCount]; + + var bX = new double[nFree]; + var bY = new double[nFree]; + var solX = new double[nFree]; + var solY = new double[nFree]; + // Seed the CG solver with the current free-vertex UVs. + for (int i = 0; i < n; i++) + { + int fi = freeOfLocal[i]; + if (fi < 0) continue; + solX[fi] = uLocal[i]; + solY[fi] = vLocal[i]; + } + + int convergedRuns = 0; + bool converged = false; + int itersDone = 0; + + for (int iter = 0; iter < iterations; iter++) + { + itersDone = iter + 1; + // Local step: best 2x2 rotation per triangle. + for (int t = 0; t < triValidCount; t++) + { + int l0 = triLocal[t * 3 + 0]; + int l1 = triLocal[t * 3 + 1]; + int l2 = triLocal[t * 3 + 2]; + double w01 = halfCot[t * 3 + 0]; + double w12 = halfCot[t * 3 + 1]; + double w20 = halfCot[t * 3 + 2]; + double rxa = rest2D[t * 6 + 0], rya = rest2D[t * 6 + 1]; + double rxb = rest2D[t * 6 + 2], ryb = rest2D[t * 6 + 3]; + double rxc = rest2D[t * 6 + 4], ryc = rest2D[t * 6 + 5]; + + double ux01 = uLocal[l1] - uLocal[l0], vy01 = vLocal[l1] - vLocal[l0]; + double ux12 = uLocal[l2] - uLocal[l1], vy12 = vLocal[l2] - vLocal[l1]; + double ux20 = uLocal[l0] - uLocal[l2], vy20 = vLocal[l0] - vLocal[l2]; + + double rx01 = rxb - rxa, ry01 = ryb - rya; + double rx12 = rxc - rxb, ry12 = ryc - ryb; + double rx20 = rxa - rxc, ry20 = rya - ryc; + + // J = Σ w * u * x̃^T (2×2). For 2D vectors, + // J00 += w * u_x * x̃_x, J01 += w * u_x * x̃_y, + // J10 += w * u_y * x̃_x, J11 += w * u_y * x̃_y. + double j00 = w01 * ux01 * rx01 + w12 * ux12 * rx12 + w20 * ux20 * rx20; + double j01 = w01 * ux01 * ry01 + w12 * ux12 * ry12 + w20 * ux20 * ry20; + double j10 = w01 * vy01 * rx01 + w12 * vy12 * rx12 + w20 * vy20 * rx20; + double j11 = w01 * vy01 * ry01 + w12 * vy12 * ry12 + w20 * vy20 * ry20; + + // Closed-form SO(2) closest rotation. R minimises + // ||J - R||² over R ∈ SO(2); R = (cos α, -sin α; sin α, cos α) + // with α = atan2(J10 - J01, J00 + J11). + double alpha = Math.Atan2(j10 - j01, j00 + j11); + rotC[t] = Math.Cos(alpha); + rotS[t] = Math.Sin(alpha); + } + + // Global step: assemble RHS, solve L_FF · u_free = b_free. + Array.Clear(bX, 0, nFree); + Array.Clear(bY, 0, nFree); + for (int t = 0; t < triValidCount; t++) + { + int l0 = triLocal[t * 3 + 0]; + int l1 = triLocal[t * 3 + 1]; + int l2 = triLocal[t * 3 + 2]; + double w01 = halfCot[t * 3 + 0]; + double w12 = halfCot[t * 3 + 1]; + double w20 = halfCot[t * 3 + 2]; + double rxa = rest2D[t * 6 + 0], rya = rest2D[t * 6 + 1]; + double rxb = rest2D[t * 6 + 2], ryb = rest2D[t * 6 + 3]; + double rxc = rest2D[t * 6 + 4], ryc = rest2D[t * 6 + 5]; + double c = rotC[t], s = rotS[t]; + + // R · x̃_edge for each oriented edge. + // edge01 = x̃1 - x̃0 + double rx01 = rxb - rxa, ry01 = ryb - rya; + double rx12 = rxc - rxb, ry12 = ryc - ryb; + double rx20 = rxa - rxc, ry20 = rya - ryc; + + double Re01x = c * rx01 - s * ry01; + double Re01y = s * rx01 + c * ry01; + double Re12x = c * rx12 - s * ry12; + double Re12y = s * rx12 + c * ry12; + double Re20x = c * rx20 - s * ry20; + double Re20y = s * rx20 + c * ry20; + + // For each oriented edge (a,b): b[a] += w * R·(x̃_a - x̃_b) + // i.e. b[a] += -w * R·(x̃_b - x̃_a); b[b] += w * R·(x̃_b - x̃_a) + AddToRhs(bX, bY, freeOfLocal, l0, -w01 * Re01x, -w01 * Re01y); + AddToRhs(bX, bY, freeOfLocal, l1, w01 * Re01x, w01 * Re01y); + AddToRhs(bX, bY, freeOfLocal, l1, -w12 * Re12x, -w12 * Re12y); + AddToRhs(bX, bY, freeOfLocal, l2, w12 * Re12x, w12 * Re12y); + AddToRhs(bX, bY, freeOfLocal, l2, -w20 * Re20x, -w20 * Re20y); + AddToRhs(bX, bY, freeOfLocal, l0, w20 * Re20x, w20 * Re20y); + } + + // Solve L_FF · x = bX (and same for Y) — Jacobi-preconditioned CG. + ConjugateGradientSolve(csrRowPtr, csrCol, csrVal, csrDiag, bX, solX, kCgMaxIter, kCgRelTol); + ConjugateGradientSolve(csrRowPtr, csrCol, csrVal, csrDiag, bY, solY, kCgMaxIter, kCgRelTol); + + // Write back into uLocal/vLocal; pinned vertex stays at 0. + for (int i = 0; i < n; i++) + { + int fi = freeOfLocal[i]; + if (fi < 0) { uLocal[i] = 0.0; vLocal[i] = 0.0; continue; } + uLocal[i] = solX[fi]; + vLocal[i] = solY[fi]; + } + + // Early-exit check: per-triangle rotation delta. + if (iter > 0) + { + double maxDelta = 0.0; + for (int t = 0; t < triValidCount; t++) + { + double dc = rotC[t] - rotCPrev[t]; + double ds = rotS[t] - rotSPrev[t]; + // Frobenius² of (R - R_prev) on 2×2 rot = 2*(dc² + ds²). + double frob2 = 2.0 * (dc * dc + ds * ds); + if (frob2 > maxDelta) maxDelta = frob2; + } + double frobMax = Math.Sqrt(maxDelta); + if (frobMax < kRotationDeltaTol) + { + convergedRuns++; + if (convergedRuns >= 2) { converged = true; break; } + } + else convergedRuns = 0; + } + + Array.Copy(rotC, rotCPrev, triValidCount); + Array.Copy(rotS, rotSPrev, triValidCount); + } + + // ── Rescale/translate new UVs to match original shell bbox ────── + // Goal: preserve the shell's UV0 footprint location so downstream + // passes (texel density normalize, perturb, xatlas pack) operate + // on the same UV scale. + double newMinU = double.MaxValue, newMaxU = double.MinValue; + double newMinV = double.MaxValue, newMaxV = double.MinValue; + for (int i = 0; i < n; i++) + { + if (uLocal[i] < newMinU) newMinU = uLocal[i]; + if (uLocal[i] > newMaxU) newMaxU = uLocal[i]; + if (vLocal[i] < newMinV) newMinV = vLocal[i]; + if (vLocal[i] > newMaxV) newMaxV = vLocal[i]; + } + double newW = Math.Max(1e-20, newMaxU - newMinU); + double newH = Math.Max(1e-20, newMaxV - newMinV); + double oldW = Math.Max(1e-20, maxU - minU); + double oldH = Math.Max(1e-20, maxV - minV); + + // ── Quality gate: only accept the new UV if it's actually better ── + // ARAP can converge to a degenerate / heavily-flipped layout when + // the input is pathological (Tutte fallback used, near-collinear + // boundary, high-curvature ribbon). In those cases overwriting the + // original UV0 with the worse result actively hurts downstream + // pack/density passes. Reject if the output is worse on any + // tracked metric and let the caller keep the original UVs. + int outFlipCount = 0; + int outDegenerate = 0; + for (int t = 0; t < triValidCount; t++) + { + int l0 = triLocal[t * 3 + 0]; + int l1 = triLocal[t * 3 + 1]; + int l2 = triLocal[t * 3 + 2]; + double a = (uLocal[l1] - uLocal[l0]) * (vLocal[l2] - vLocal[l0]) + - (uLocal[l2] - uLocal[l0]) * (vLocal[l1] - vLocal[l0]); + if (a < 0) outFlipCount++; + if (Math.Abs(a) < 1e-12) outDegenerate++; + } + double outBboxArea = Math.Max(0.0, newMaxU - newMinU) * Math.Max(0.0, newMaxV - newMinV); + // initialFlipCount above is the count BEFORE mirror-fix; compare + // against `flipped`, the post-mirror count, so the gate is + // consistent with the layout we actually started ARAP with. + int gateInitFlips = flipped; + bool rejectFlips = outFlipCount > gateInitFlips; + bool rejectDegenerate = outDegenerate > triValidCount * 0.05; + bool rejectCollapse = outBboxArea < bboxArea * 0.3; + if (rejectFlips || rejectDegenerate || rejectCollapse) + { + UvtLog.Verbose(UvtLog.Category.Repack, + $"[ARAP] shell verts={n} tris={triValidCount}/{triCount} REJECTED: " + + $"outFlips={outFlipCount}/{triValidCount} (init={gateInitFlips}), " + + $"outDegenerate={outDegenerate}, bboxArea={outBboxArea:G3}/{bboxArea:G3} " + + $"→ keeping original UV0"); + return false; + } + + // Uniform area-preserving scale so the new UV bbox has the same + // area as the original (texel-density normalization later will + // re-scale anyway, but we keep the order of magnitude sane). + double oldArea = oldW * oldH; + double newArea = newW * newH; + double scale = Math.Sqrt(oldArea / newArea); + if (!IsFiniteD(scale) || scale <= 0.0) scale = 1.0; + + double targetCx = 0.5 * (minU + maxU) + cx; // back into global UV system + double targetCy = 0.5 * (minV + maxV) + cy; + double srcCx = 0.5 * (newMinU + newMaxU); + double srcCy = 0.5 * (newMinV + newMaxV); + + // Two-pass write so a NaN bail-out doesn't leave UVs in a mixed + // state (first compute everything into temporary buffers; only + // copy to uvFlat after all values pass the finite-ness check). + var outU = new double[n]; + var outV = new double[n]; + for (int i = 0; i < n; i++) + { + outU[i] = (uLocal[i] - srcCx) * scale + targetCx; + outV[i] = (vLocal[i] - srcCy) * scale + targetCy; + if (!IsFiniteD(outU[i]) || !IsFiniteD(outV[i])) return false; + } + for (int i = 0; i < n; i++) + { + int gi = globalOf[i]; + int idx = gi * 2; + if ((uint)idx + 1 >= (uint)uvFlat.Length) continue; + uvFlat[idx] = (float)outU[i]; + uvFlat[idx + 1] = (float)outV[i]; + } + + UvtLog.Verbose(UvtLog.Category.Repack, + $"[ARAP] shell verts={n} tris={triValidCount}/{triCount} (degenerate={degenerateTris}) iters={itersDone}/{iterations} initFlipped={initialFlipCount} converged={converged}"); + + return true; + } + + // ──────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────── + + static void AccumulateLaplacian( + Dictionary[] rowMaps, double[] diag, + int i, int j, double w) + { + if (i == j || w == 0.0) return; + // Off-diag += w (so L[i,j] = -Σ contributions). + rowMaps[i].TryGetValue(j, out double cur); + rowMaps[i][j] = cur + w; + rowMaps[j].TryGetValue(i, out double cur2); + rowMaps[j][i] = cur2 + w; + diag[i] += w; + diag[j] += w; + } + + static void AddToRhs(double[] bX, double[] bY, int[] freeOfLocal, + int localIdx, double dx, double dy) + { + int fi = freeOfLocal[localIdx]; + if (fi < 0) return; + bX[fi] += dx; + bY[fi] += dy; + } + + /// + /// Find a boundary loop for the shell. Boundary edges are half-edges + /// that appear only once in this shell's triangle list. Walks the + /// boundary as an ordered cycle starting from the lowest local index + /// for determinism. Returns the longest cycle found. + /// + static List FindBoundaryLoop(int[] triLocal, int triCount, int n, out bool hasBoundary) + { + hasBoundary = false; + // Half-edge count per directed pair (a,b) with a(); + // Directed half-edges for boundary traversal: from each vertex + // we store the next vertex in the half-edge that has no twin. + for (int t = 0; t < triCount; t++) + { + int a = triLocal[t * 3 + 0]; + int b = triLocal[t * 3 + 1]; + int c = triLocal[t * 3 + 2]; + BumpEdge(edgeCount, a, b); + BumpEdge(edgeCount, b, c); + BumpEdge(edgeCount, c, a); + } + + // Collect boundary edges as undirected pairs whose count == 1. + // For each boundary vertex, list its boundary neighbors. + var nbrs = new Dictionary>(); + foreach (var kv in edgeCount) + { + if (kv.Value != 1) continue; + long key = kv.Key; + int lo = (int)(key >> 32); + int hi = (int)(key & 0xFFFFFFFFL); + AddNeighbor(nbrs, lo, hi); + AddNeighbor(nbrs, hi, lo); + } + + if (nbrs.Count == 0) return new List(); + hasBoundary = true; + + // Walk the boundary starting from the lowest-index boundary vertex + // and following any available neighbor. This finds a single cycle + // (boundary vertices have valence-2 in the boundary subgraph for + // a manifold open patch). + var visited = new HashSet(); + int start = int.MaxValue; + foreach (var k in nbrs.Keys) if (k < start) start = k; + + var loop = new List(); + int cur = start; + int prev = -1; + while (true) + { + if (visited.Contains(cur)) break; + visited.Add(cur); + loop.Add(cur); + if (!nbrs.TryGetValue(cur, out var list) || list.Count == 0) break; + int next = -1; + for (int i = 0; i < list.Count; i++) + { + if (list[i] != prev) { next = list[i]; break; } + } + if (next < 0) break; + prev = cur; + cur = next; + if (cur == start) break; + } + return loop; + } + + static void BumpEdge(Dictionary edgeCount, int a, int b) + { + if (a == b) return; + int lo = a < b ? a : b; + int hi = a < b ? b : a; + long key = ((long)lo << 32) | (uint)hi; + edgeCount.TryGetValue(key, out int c); + edgeCount[key] = c + 1; + } + + static void AddNeighbor(Dictionary> nbrs, int from, int to) + { + if (!nbrs.TryGetValue(from, out var list)) + { + list = new List(2); + nbrs[from] = list; + } + list.Add(to); + } + + /// + /// Tutte embedding: pin boundary vertices to the unit circle, solve + /// L_inner · u_inner = -L_boundary_inner · u_boundary for interior + /// positions. Used as ARAP initial guess when the input UV0 is + /// degenerate. + /// + static bool TutteEmbed( + List boundaryLoop, + Dictionary[] rowMaps, + double[] diag, + int n, + double[] outU, + double[] outV) + { + if (boundaryLoop == null || boundaryLoop.Count < 3) return false; + + // Mark boundary vertices and assign each one a circle position. + var isBoundary = new bool[n]; + var bU = new double[n]; + var bV = new double[n]; + int bCount = boundaryLoop.Count; + for (int i = 0; i < bCount; i++) + { + int v = boundaryLoop[i]; + if (v < 0 || v >= n) return false; + isBoundary[v] = true; + double theta = 2.0 * Math.PI * i / bCount; + bU[v] = Math.Cos(theta); + bV[v] = Math.Sin(theta); + } + + // Interior free indices. + int nInner = n - bCount; + if (nInner == 0) + { + for (int i = 0; i < n; i++) + { + outU[i] = bU[i]; + outV[i] = bV[i]; + } + return true; + } + var freeOfLocal = new int[n]; + int fi = 0; + for (int i = 0; i < n; i++) + { + if (isBoundary[i]) { freeOfLocal[i] = -1; } + else freeOfLocal[i] = fi++; + } + + // Build CSR of the inner×inner Laplacian and RHS from boundary + // constraints. + var rowPtr = new int[nInner + 1]; + var counts = new int[nInner]; + for (int i = 0; i < n; i++) + { + int ri = freeOfLocal[i]; + if (ri < 0) continue; + int cnt = 0; + foreach (var kv in rowMaps[i]) + { + int rj = freeOfLocal[kv.Key]; + if (rj < 0) continue; + if (kv.Value == 0.0) continue; + cnt++; + } + counts[ri] = cnt; + } + rowPtr[0] = 0; + for (int i = 0; i < nInner; i++) rowPtr[i + 1] = rowPtr[i] + counts[i]; + int nnz = rowPtr[nInner]; + var col = new int[nnz]; + var val = new double[nnz]; + var diagFree = new double[nInner]; + var rhsU = new double[nInner]; + var rhsV = new double[nInner]; + var cursor = new int[nInner]; + + for (int i = 0; i < n; i++) + { + int ri = freeOfLocal[i]; + if (ri < 0) continue; + diagFree[ri] = diag[i]; + int baseOff = rowPtr[ri]; + int cur = 0; + foreach (var kv in rowMaps[i]) + { + int rj = freeOfLocal[kv.Key]; + double w = kv.Value; + if (w == 0.0) continue; + if (rj < 0) + { + // Boundary contribution: L · u_boundary moves into RHS. + // L[i,j_bnd] = -w, and we solve L·u = 0 (Tutte = harmonic), + // so rhs[ri] = -(-w * u_boundary[j]) = w * u_boundary[j]. + int gj = kv.Key; + rhsU[ri] += w * bU[gj]; + rhsV[ri] += w * bV[gj]; + continue; + } + col[baseOff + cur] = rj; + val[baseOff + cur] = -w; + cur++; + } + cursor[ri] = cur; + } + + var solU = new double[nInner]; + var solV = new double[nInner]; + ConjugateGradientSolve(rowPtr, col, val, diagFree, rhsU, solU, kCgMaxIter, kCgRelTol); + ConjugateGradientSolve(rowPtr, col, val, diagFree, rhsV, solV, kCgMaxIter, kCgRelTol); + + for (int i = 0; i < n; i++) + { + int ri = freeOfLocal[i]; + if (ri < 0) { outU[i] = bU[i]; outV[i] = bV[i]; } + else { outU[i] = solU[ri]; outV[i] = solV[ri]; } + if (!IsFiniteD(outU[i]) || !IsFiniteD(outV[i])) return false; + } + return true; + } + + /// + /// Solve L · x = b where L is symmetric positive (semi-)definite, + /// stored as CSR + separate diagonal array. Uses Jacobi + /// preconditioning M⁻¹ = diag(L)⁻¹. x carries the initial guess + /// in and the solution out. + /// + static bool ConjugateGradientSolve( + int[] rowPtr, int[] col, double[] val, double[] diag, + double[] b, double[] x, + int maxIter, double relTol) + { + int n = b.Length; + if (n == 0) return true; + var r = new double[n]; + var z = new double[n]; + var p = new double[n]; + var Ap = new double[n]; + + // r = b - A·x + SpMv(rowPtr, col, val, diag, x, Ap); + double bNorm2 = 0.0; + for (int i = 0; i < n; i++) + { + r[i] = b[i] - Ap[i]; + bNorm2 += b[i] * b[i]; + } + double rNorm2 = 0.0; + for (int i = 0; i < n; i++) rNorm2 += r[i] * r[i]; + double tol2 = relTol * relTol * Math.Max(bNorm2, 1e-30); + if (rNorm2 <= tol2) return true; + + // z = M⁻¹ r, p = z + for (int i = 0; i < n; i++) + { + double d = diag[i]; + z[i] = d > 0.0 ? r[i] / d : r[i]; + p[i] = z[i]; + } + double rz = 0.0; + for (int i = 0; i < n; i++) rz += r[i] * z[i]; + + for (int it = 0; it < maxIter; it++) + { + SpMv(rowPtr, col, val, diag, p, Ap); + double pAp = 0.0; + for (int i = 0; i < n; i++) pAp += p[i] * Ap[i]; + if (pAp <= 0.0 || double.IsNaN(pAp)) return false; + double alpha = rz / pAp; + rNorm2 = 0.0; + for (int i = 0; i < n; i++) + { + x[i] += alpha * p[i]; + r[i] -= alpha * Ap[i]; + rNorm2 += r[i] * r[i]; + } + if (rNorm2 <= tol2) return true; + + for (int i = 0; i < n; i++) + { + double d = diag[i]; + z[i] = d > 0.0 ? r[i] / d : r[i]; + } + double rzNew = 0.0; + for (int i = 0; i < n; i++) rzNew += r[i] * z[i]; + double beta = rzNew / Math.Max(rz, 1e-30); + for (int i = 0; i < n; i++) p[i] = z[i] + beta * p[i]; + rz = rzNew; + } + return false; // not converged within maxIter — caller still uses x + } + + /// Sparse mat-vec: y = A · x for A stored as CSR off-diagonals + separate diag. + static void SpMv(int[] rowPtr, int[] col, double[] val, double[] diag, + double[] x, double[] y) + { + int n = diag.Length; + for (int i = 0; i < n; i++) + { + double acc = diag[i] * x[i]; + int start = rowPtr[i], end = rowPtr[i + 1]; + for (int k = start; k < end; k++) acc += val[k] * x[col[k]]; + y[i] = acc; + } + } + + static bool IsFiniteD(double x) + { + return !(double.IsNaN(x) || double.IsInfinity(x)); + } + } +} diff --git a/Editor/ArapParameterization.cs.meta b/Editor/ArapParameterization.cs.meta new file mode 100644 index 00000000..e359924d --- /dev/null +++ b/Editor/ArapParameterization.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 79a6b99882254c94babc29e56decaae7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs new file mode 100644 index 00000000..cca64afa --- /dev/null +++ b/Editor/BenchmarkRecorder.cs @@ -0,0 +1,545 @@ +// BenchmarkRecorder.cs — Machine-readable metrics capture for transfer pipeline runs. +// Wrapped around ExecFullPipeline / ExecRepack / ExecTransferAll; writes CSV + JSON +// into /BenchmarkReports/ on Dispose. See TRANSFER_BENCHMARK.md. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + /// + /// Collects per-mesh metrics during a pipeline run and writes CSV + JSON on Dispose. + /// Construct via ; wrap the run in using(...). + /// + public sealed class BenchmarkRecorder : IDisposable + { + public static BenchmarkRecorder Current { get; private set; } + + /// + /// Absolute path of the most recent CSV written by . + /// Used by to locate per-cell artefacts after a + /// nested run finishes (the sweep driver does not own the recorder session, + /// so it can't get the path from Current — which has already been cleared). + /// + public static string LastWrittenCsvPath { get; private set; } + + // Sentinel for nested calls — caller treats it as a scope that does nothing on Dispose. + sealed class NoOpScope : IDisposable { public static readonly NoOpScope Instance = new NoOpScope(); public void Dispose() { } } + + // ── Session state ── + readonly string runLabel; + readonly string lodGroupName; + readonly string modeTag; + readonly DateTime startedAtUtc; + readonly Stopwatch wallClock = Stopwatch.StartNew(); + + // Per-stage timings (pipelineMs / repackMs / transferMs / validateMs) + readonly Dictionary stageTimers = new Dictionary(); + readonly Dictionary stageAccum = new Dictionary(); + + // Pipeline-wide metrics + int symSplitFallbackAt0 = -1; + int symSplitTotalAt0 = -1; + + // Session config snapshot + readonly string symSplitMode; + readonly bool repackPerMesh; + readonly bool splitTargets; + // Resolved (post auto-compute) atlas resolution. The constructor seeds + // it with ctx.AtlasResolution; ExecRepackCore overrides it via + // SetResolvedAtlasResolution after MeshAreaHelper.ComputeAutoResolution + // so AutoFromTexelDensity runs record the value xatlas actually packed + // at, not the user-facing setting. + int atlasResolution; + readonly int shellPad; + readonly int borderPad; + readonly int sourceLodIndex; + // Pre-pack params snapshot — captured from UvToolContext so each cell + // of a parameter sweep stamps its CSV/JSON with the values that + // produced the run, regardless of subsequent context mutations. + readonly bool arapEnabled; + readonly int arapIterations; + readonly float stretchThreshold; + // TODO: capture actualAtlasWidth/actualAtlasHeight from RepackResult. + // Currently RepackResult is consumed inside ExecRepackCore and not + // surfaced on MeshEntry. Threading it through would require a new + // field on MeshEntry — out of scope for this iteration. + + // Per-mesh records (one row per recorded mesh) + readonly List records = new List(); + + BenchmarkRecorder(UvToolContext ctx, string label, bool splitTargetsFlag, + SymmetrySplitShells.ThresholdMode symMode) + { + runLabel = string.IsNullOrEmpty(label) ? "run" : Sanitize(label); + lodGroupName = ctx?.LodGroup != null ? Sanitize(ctx.LodGroup.name) : "standalone"; + symSplitMode = symMode.ToString(); + repackPerMesh = ctx?.RepackPerMesh ?? false; + splitTargets = splitTargetsFlag; + atlasResolution = ctx?.AtlasResolution ?? 0; + shellPad = ctx?.ShellPaddingPx ?? 0; + borderPad = ctx?.BorderPaddingPx ?? 0; + sourceLodIndex = ctx?.SourceLodIndex ?? 0; + arapEnabled = ctx?.ReparameterizeStretchedShells ?? false; + arapIterations = ctx?.ArapIterations ?? 0; + stretchThreshold = ctx?.StretchThreshold ?? 0f; + modeTag = $"{symSplitMode}{(repackPerMesh ? "-perMesh" : "")}{(splitTargets ? "-splitTgt" : "")}"; + startedAtUtc = DateTime.UtcNow; + + // Reset volatile counters so values in this run only reflect this run. + SymmetrySplitShells.LastFallbackCount = 0; + SymmetrySplitShells.LastTotalSplitCount = 0; + symSplitFallbackAt0 = 0; + symSplitTotalAt0 = 0; + } + + /// + /// Begin a new recording session. Nested calls are no-ops — the outer session + /// captures everything and the inner caller gets a scope whose Dispose does nothing. + /// Always call inside `using (BenchmarkRecorder.NewRun(...)) { ... }`. + /// + public static IDisposable NewRun(UvToolContext ctx, string label, + bool splitTargets, SymmetrySplitShells.ThresholdMode symMode) + { + if (Current != null) return NoOpScope.Instance; + Current = new BenchmarkRecorder(ctx, label, splitTargets, symMode); + return Current; + } + + /// + /// Update the recorded atlas resolution to the value the repack stage + /// actually used. Needed when + /// computes a resolution at runtime that differs from + /// ctx.AtlasResolution — the CSV/JSON would otherwise report + /// the stale UI setting and break sweep aggregations that key off + /// atlasRes. + /// + public void SetResolvedAtlasResolution(int resolved) + { + if (resolved > 0) atlasResolution = resolved; + } + + // ── Stage timing ── + public void StageBegin(string stage) + { + if (!stageTimers.TryGetValue(stage, out var sw)) + { + sw = new Stopwatch(); + stageTimers[stage] = sw; + } + sw.Restart(); + } + + public void StageEnd(string stage) + { + if (stageTimers.TryGetValue(stage, out var sw)) + { + sw.Stop(); + stageAccum.TryGetValue(stage, out var accum); + stageAccum[stage] = accum + sw.ElapsedMilliseconds; + } + } + + /// + /// Capture per-mesh record: TransferResult, ValidationReport, and a snapshot + /// of the volatile SymSplit/Topology counters. Call once per target mesh after + /// validation has populated . + /// Also snapshots UV2 + triangles so WriteArtefacts can dump a PNG per mesh. + /// + public void RecordMesh(MeshEntry entry) + { + if (entry == null || entry.renderer == null) return; + + var tr = entry.shellTransferResult; + var vr = entry.validationReport; + + // Pick the mesh whose UV2 reflects the pipeline result: + // source LOD → repackedMesh if present + // target LOD → transferredMesh if present + // Fall back to originalMesh if neither exists (bare re-run). + Mesh snapshotMesh = + (entry.lodIndex == sourceLodIndex ? entry.repackedMesh : entry.transferredMesh) + ?? entry.originalMesh; + + Vector2[] uv2Snap = null; + int[] trisSnap = null; + if (snapshotMesh != null) + { + var list = new System.Collections.Generic.List(); + snapshotMesh.GetUVs(1, list); + if (list.Count > 0) + { + uv2Snap = list.ToArray(); + trisSnap = snapshotMesh.triangles; + } + } + + // Validation report can be stale: a mesh that failed transfer in + // a later sweep cell would otherwise carry the previous cell's + // ValidationReport into this row. Gate validation fields on + // "transfer actually happened this run" (= tr != null). + var v = (tr != null) ? vr : null; + // SymSplit counters are session-level (delta from session start). + // Writing them on every row would cause SUM aggregations to scale + // by mesh count; pin them to the first row of the session. + bool firstRow = records.Count == 0; + + var rec = new RunRecord + { + timestamp = DateTime.UtcNow, + rendererName = entry.renderer.name, + meshGroupKey = entry.meshGroupKey ?? "", + lodIndex = entry.lodIndex, + isSourceLod = entry.lodIndex == sourceLodIndex, + shellsMatched = tr?.shellsMatched ?? 0, + shellsUnmatched = tr?.shellsUnmatched ?? 0, + shellsTransform = tr?.shellsTransform ?? 0, + shellsInterpolation = tr?.shellsInterpolation ?? 0, + shellsMerged = tr?.shellsMerged ?? 0, + shellsRejected = tr?.shellsRejected ?? 0, + shellsOverlapFixed = tr?.shellsOverlapFixed ?? 0, + dedupConflicts = tr?.dedupConflicts ?? 0, + fragmentsMerged = tr?.fragmentsMerged ?? 0, + consistencyCorrected = tr?.consistencyCorrected ?? 0, + verticesTransferred = tr?.verticesTransferred ?? 0, + verticesTotal = tr?.verticesTotal ?? 0, + + invertedCount = v?.invertedCount ?? 0, + stretchedCount = v?.stretchedCount ?? 0, + zeroAreaCount = v?.zeroAreaCount ?? 0, + oobCount = v?.oobCount ?? 0, + cleanCount = v?.cleanCount ?? 0, + overlapShellPairs = v?.overlapShellPairs ?? 0, + overlapTriangleCount= v?.overlapTriangleCount?? 0, + overlapSameSrcPairs = v?.overlapSameSrcPairs ?? 0, + texelDensityBadCount= v?.texelDensityBadCount?? 0, + texelDensityMedian = v?.texelDensityMedian ?? 0f, + + // Run-level — written only on the first row of the session so + // SUM aggregations don't multiply them by mesh count. + symSplitFallbackCount = firstRow ? (SymmetrySplitShells.LastFallbackCount - symSplitFallbackAt0) : 0, + symSplitTotalCount = firstRow ? (SymmetrySplitShells.LastTotalSplitCount - symSplitTotalAt0) : 0, + // Topology counters are captured per-target by Transfer() into + // TransferResult. The static Last* fields are unreliable here — + // they reflect only the last processed mesh in a multi-target run. + topologyIterations = tr?.topologyIterations ?? 0, + topologyFixed = tr?.topologyFixed ?? 0, + topologyCapHit = tr?.topologyCapHit ?? false, + + uv2Snapshot = uv2Snap, + trianglesSnapshot = trisSnap, + }; + + // atlasUtilization = sum of |triangle area| in UV2 space — true + // chart coverage of [0,1]² (1.0 = full atlas, bin-packing + // typically lands at 0.55-0.85). Uses the same triangle-sum + // helper RepackSingle/Multi log so the metric is consistent with + // the per-mesh log line. The previous bbox-based version dropped + // any UV with sqrMagnitude near zero (excluding legitimate verts + // at the atlas origin) and reported bbox area instead of true + // coverage, so layouts touching (0,0) under-reported. + if (uv2Snap != null && uv2Snap.Length > 0 && trisSnap != null) + { + rec.atlasUtilization = (float)XatlasRepack.ComputeUv2CoverageFraction(uv2Snap, trisSnap); + } + records.Add(rec); + } + + // ── Dispose writes artefacts ── + public void Dispose() + { + if (Current != this) return; // already finalized + wallClock.Stop(); + try + { + WriteArtefacts(); + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, $"Failed to write report: {ex.Message}"); + } + finally + { + Current = null; + } + } + + void WriteArtefacts() + { + // Only emit artefacts for runs that produced per-mesh data. + // Bare repack/transfer runs without RecordMesh calls aren't worth a file. + if (records.Count == 0) return; + + string projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + string dir = Path.Combine(projectRoot, "BenchmarkReports"); + Directory.CreateDirectory(dir); + + // Millisecond-precision timestamp — second-level collided when an + // operator rerun the same mode/label within ~1 second (scripted + // sweeps or quick UI clicks). Adding ms ensures every run writes + // to its own file even at sub-second cadence. + string stamp = startedAtUtc.ToString("yyyyMMdd_HHmmss_fff"); + string fileBase = $"{stamp}_{lodGroupName}_{runLabel}_{Sanitize(modeTag)}"; + string csvPath = Path.Combine(dir, fileBase + ".csv"); + string jsonPath = Path.Combine(dir, fileBase + ".json"); + + File.WriteAllText(csvPath, BuildCsv(), Encoding.UTF8); + File.WriteAllText(jsonPath, BuildJson(), Encoding.UTF8); + // Publish path so external orchestrators (e.g. BenchmarkSweep) can + // locate the artefacts of the most recently finished session. + LastWrittenCsvPath = csvPath; + + // Per-mesh UV2 snapshots, one PNG per recorded mesh. + int pngCount = 0; + string pngDir = Path.Combine(dir, fileBase + "_png"); + foreach (var r in records) + { + if (r.uv2Snapshot == null || r.trianglesSnapshot == null) continue; + string pngName = Sanitize(r.rendererName) + $"_LOD{r.lodIndex}_uv2.png"; + if (UvPngWriter.Render(Path.Combine(pngDir, pngName), + r.uv2Snapshot, r.trianglesSnapshot)) + pngCount++; + } + + UvtLog.Info(UvtLog.Category.Benchmark, + $"saved {records.Count} rec(s){(pngCount > 0 ? $" + {pngCount} PNG" : "")} → {csvPath}"); + } + + string BuildCsv() + { + var sb = new StringBuilder(); + sb.AppendLine("timestamp,runLabel,lodGroup,symSplitMode,repackPerMesh,splitTargets," + + "atlasRes,shellPad,borderPad," + + "arapEnabled,arapIterations,stretchThreshold," + + "sourceLod," + + "rendererName,meshGroupKey,lodIndex,isSourceLod," + + "shellsMatched,shellsUnmatched,shellsTransform,shellsInterpolation,shellsMerged," + + "shellsRejected,shellsOverlapFixed,dedupConflicts,fragmentsMerged,consistencyCorrected," + + "verticesTransferred,verticesTotal," + + "invertedCount,stretchedCount,zeroAreaCount,oobCount,cleanCount," + + "overlapShellPairs,overlapTriangleCount,overlapSameSrcPairs," + + "texelDensityBadCount,texelDensityMedian," + + "symSplitFallbackCount,symSplitTotalCount," + + "topologyIterations,topologyFixed,topologyCapHit,atlasUtilization," + + "pipelineMs,repackMs,transferMs,validateMs"); + + long pipelineMs = stageAccum.TryGetValue("pipeline", out var pm) ? pm : 0; + long repackMs = stageAccum.TryGetValue("repack", out var rm) ? rm : 0; + long transferMs = stageAccum.TryGetValue("transfer", out var tm) ? tm : 0; + long validateMs = stageAccum.TryGetValue("validate", out var vm) ? vm : 0; + + var inv = CultureInfo.InvariantCulture; + foreach (var r in records) + { + sb.Append(r.timestamp.ToString("o", inv)).Append(','); + sb.Append(Csv(runLabel)).Append(','); + sb.Append(Csv(lodGroupName)).Append(','); + sb.Append(Csv(symSplitMode)).Append(','); + sb.Append(repackPerMesh ? '1' : '0').Append(','); + sb.Append(splitTargets ? '1' : '0').Append(','); + sb.Append(atlasResolution.ToString(inv)).Append(','); + sb.Append(shellPad.ToString(inv)).Append(','); + sb.Append(borderPad.ToString(inv)).Append(','); + sb.Append(arapEnabled ? '1' : '0').Append(','); + sb.Append(arapIterations.ToString(inv)).Append(','); + sb.Append(stretchThreshold.ToString("R", inv)).Append(','); + sb.Append(sourceLodIndex.ToString(inv)).Append(','); + sb.Append(Csv(r.rendererName)).Append(','); + sb.Append(Csv(r.meshGroupKey)).Append(','); + sb.Append(r.lodIndex.ToString(inv)).Append(','); + sb.Append(r.isSourceLod ? '1' : '0').Append(','); + sb.Append(r.shellsMatched.ToString(inv)).Append(','); + sb.Append(r.shellsUnmatched.ToString(inv)).Append(','); + sb.Append(r.shellsTransform.ToString(inv)).Append(','); + sb.Append(r.shellsInterpolation.ToString(inv)).Append(','); + sb.Append(r.shellsMerged.ToString(inv)).Append(','); + sb.Append(r.shellsRejected.ToString(inv)).Append(','); + sb.Append(r.shellsOverlapFixed.ToString(inv)).Append(','); + sb.Append(r.dedupConflicts.ToString(inv)).Append(','); + sb.Append(r.fragmentsMerged.ToString(inv)).Append(','); + sb.Append(r.consistencyCorrected.ToString(inv)).Append(','); + sb.Append(r.verticesTransferred.ToString(inv)).Append(','); + sb.Append(r.verticesTotal.ToString(inv)).Append(','); + sb.Append(r.invertedCount.ToString(inv)).Append(','); + sb.Append(r.stretchedCount.ToString(inv)).Append(','); + sb.Append(r.zeroAreaCount.ToString(inv)).Append(','); + sb.Append(r.oobCount.ToString(inv)).Append(','); + sb.Append(r.cleanCount.ToString(inv)).Append(','); + sb.Append(r.overlapShellPairs.ToString(inv)).Append(','); + sb.Append(r.overlapTriangleCount.ToString(inv)).Append(','); + sb.Append(r.overlapSameSrcPairs.ToString(inv)).Append(','); + sb.Append(r.texelDensityBadCount.ToString(inv)).Append(','); + sb.Append(r.texelDensityMedian.ToString("R", inv)).Append(','); + sb.Append(r.symSplitFallbackCount.ToString(inv)).Append(','); + sb.Append(r.symSplitTotalCount.ToString(inv)).Append(','); + sb.Append(r.topologyIterations.ToString(inv)).Append(','); + sb.Append(r.topologyFixed.ToString(inv)).Append(','); + sb.Append(r.topologyCapHit ? '1' : '0').Append(','); + sb.Append(r.atlasUtilization.ToString("R", inv)).Append(','); + sb.Append(pipelineMs.ToString(inv)).Append(','); + sb.Append(repackMs.ToString(inv)).Append(','); + sb.Append(transferMs.ToString(inv)).Append(','); + sb.Append(validateMs.ToString(inv)); + sb.AppendLine(); + } + return sb.ToString(); + } + + string BuildJson() + { + long pipelineMs = stageAccum.TryGetValue("pipeline", out var pm) ? pm : 0; + long repackMs = stageAccum.TryGetValue("repack", out var rm) ? rm : 0; + long transferMs = stageAccum.TryGetValue("transfer", out var tm) ? tm : 0; + long validateMs = stageAccum.TryGetValue("validate", out var vm) ? vm : 0; + + var sb = new StringBuilder(); + sb.Append("{\n"); + AppendJsonKv(sb, "startedAtUtc", startedAtUtc.ToString("o", CultureInfo.InvariantCulture)); sb.Append(",\n"); + AppendJsonKv(sb, "runLabel", runLabel); sb.Append(",\n"); + AppendJsonKv(sb, "lodGroup", lodGroupName); sb.Append(",\n"); + AppendJsonKv(sb, "symSplitMode", symSplitMode); sb.Append(",\n"); + AppendJsonKv(sb, "repackPerMesh", repackPerMesh); sb.Append(",\n"); + AppendJsonKv(sb, "splitTargets", splitTargets); sb.Append(",\n"); + AppendJsonKv(sb, "atlasResolution", atlasResolution); sb.Append(",\n"); + AppendJsonKv(sb, "shellPad", shellPad); sb.Append(",\n"); + AppendJsonKv(sb, "borderPad", borderPad); sb.Append(",\n"); + AppendJsonKv(sb, "arapEnabled", arapEnabled); sb.Append(",\n"); + AppendJsonKv(sb, "arapIterations", arapIterations); sb.Append(",\n"); + AppendJsonKv(sb, "stretchThreshold", stretchThreshold); sb.Append(",\n"); + AppendJsonKv(sb, "sourceLodIndex", sourceLodIndex); sb.Append(",\n"); + AppendJsonKv(sb, "pipelineMs", pipelineMs); sb.Append(",\n"); + AppendJsonKv(sb, "repackMs", repackMs); sb.Append(",\n"); + AppendJsonKv(sb, "transferMs", transferMs); sb.Append(",\n"); + AppendJsonKv(sb, "validateMs", validateMs); sb.Append(",\n"); + + sb.Append(" \"records\": [\n"); + for (int i = 0; i < records.Count; i++) + { + var r = records[i]; + sb.Append(" {"); + AppendJsonKv(sb, "timestamp", r.timestamp.ToString("o", CultureInfo.InvariantCulture)); sb.Append(", "); + AppendJsonKv(sb, "rendererName", r.rendererName); sb.Append(", "); + AppendJsonKv(sb, "meshGroupKey", r.meshGroupKey); sb.Append(", "); + AppendJsonKv(sb, "lodIndex", r.lodIndex); sb.Append(", "); + AppendJsonKv(sb, "isSourceLod", r.isSourceLod); sb.Append(", "); + AppendJsonKv(sb, "shellsMatched", r.shellsMatched); sb.Append(", "); + AppendJsonKv(sb, "shellsUnmatched", r.shellsUnmatched); sb.Append(", "); + AppendJsonKv(sb, "shellsTransform", r.shellsTransform); sb.Append(", "); + AppendJsonKv(sb, "shellsInterpolation", r.shellsInterpolation); sb.Append(", "); + AppendJsonKv(sb, "shellsMerged", r.shellsMerged); sb.Append(", "); + AppendJsonKv(sb, "shellsRejected", r.shellsRejected); sb.Append(", "); + AppendJsonKv(sb, "shellsOverlapFixed", r.shellsOverlapFixed); sb.Append(", "); + AppendJsonKv(sb, "dedupConflicts", r.dedupConflicts); sb.Append(", "); + AppendJsonKv(sb, "fragmentsMerged", r.fragmentsMerged); sb.Append(", "); + AppendJsonKv(sb, "consistencyCorrected", r.consistencyCorrected); sb.Append(", "); + AppendJsonKv(sb, "verticesTransferred", r.verticesTransferred); sb.Append(", "); + AppendJsonKv(sb, "verticesTotal", r.verticesTotal); sb.Append(", "); + AppendJsonKv(sb, "invertedCount", r.invertedCount); sb.Append(", "); + AppendJsonKv(sb, "stretchedCount", r.stretchedCount); sb.Append(", "); + AppendJsonKv(sb, "zeroAreaCount", r.zeroAreaCount); sb.Append(", "); + AppendJsonKv(sb, "oobCount", r.oobCount); sb.Append(", "); + AppendJsonKv(sb, "cleanCount", r.cleanCount); sb.Append(", "); + AppendJsonKv(sb, "overlapShellPairs", r.overlapShellPairs); sb.Append(", "); + AppendJsonKv(sb, "overlapTriangleCount", r.overlapTriangleCount); sb.Append(", "); + AppendJsonKv(sb, "overlapSameSrcPairs", r.overlapSameSrcPairs); sb.Append(", "); + AppendJsonKv(sb, "texelDensityBadCount", r.texelDensityBadCount); sb.Append(", "); + AppendJsonKv(sb, "texelDensityMedian", r.texelDensityMedian); sb.Append(", "); + AppendJsonKv(sb, "symSplitFallbackCount",r.symSplitFallbackCount);sb.Append(", "); + AppendJsonKv(sb, "symSplitTotalCount", r.symSplitTotalCount); sb.Append(", "); + AppendJsonKv(sb, "topologyIterations", r.topologyIterations); sb.Append(", "); + AppendJsonKv(sb, "topologyFixed", r.topologyFixed); sb.Append(", "); + AppendJsonKv(sb, "topologyCapHit", r.topologyCapHit); sb.Append(", "); + AppendJsonKv(sb, "atlasUtilization", r.atlasUtilization); + sb.Append("}"); + if (i < records.Count - 1) sb.Append(','); + sb.Append('\n'); + } + sb.Append(" ]\n}\n"); + return sb.ToString(); + } + + // ── Helpers ── + static void AppendJsonKv(StringBuilder sb, string k, string v) + { + sb.Append('"').Append(k).Append("\": "); + if (v == null) sb.Append("null"); + else { sb.Append('"'); AppendJsonString(sb, v); sb.Append('"'); } + } + static void AppendJsonKv(StringBuilder sb, string k, int v) { sb.Append('"').Append(k).Append("\": ").Append(v.ToString(CultureInfo.InvariantCulture)); } + static void AppendJsonKv(StringBuilder sb, string k, long v) { sb.Append('"').Append(k).Append("\": ").Append(v.ToString(CultureInfo.InvariantCulture)); } + static void AppendJsonKv(StringBuilder sb, string k, float v) { sb.Append('"').Append(k).Append("\": ").Append(v.ToString("R", CultureInfo.InvariantCulture)); } + static void AppendJsonKv(StringBuilder sb, string k, bool v) { sb.Append('"').Append(k).Append("\": ").Append(v ? "true" : "false"); } + + static void AppendJsonString(StringBuilder sb, string s) + { + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) sb.Append($"\\u{(int)c:X4}"); + else sb.Append(c); + break; + } + } + } + + static string Csv(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; + if (!needQuote) return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; + } + + static string Sanitize(string s) + { + if (string.IsNullOrEmpty(s)) return "unnamed"; + var sb = new StringBuilder(s.Length); + foreach (char c in s) + sb.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '_'); + return sb.ToString(); + } + + // ── Record struct ── + public class RunRecord + { + public DateTime timestamp; + public string rendererName; + public string meshGroupKey; + public int lodIndex; + public bool isSourceLod; + + public int shellsMatched, shellsUnmatched, shellsTransform, shellsInterpolation, shellsMerged; + public int shellsRejected, shellsOverlapFixed, dedupConflicts, fragmentsMerged, consistencyCorrected; + public int verticesTransferred, verticesTotal; + + public int invertedCount, stretchedCount, zeroAreaCount, oobCount, cleanCount; + public int overlapShellPairs, overlapTriangleCount, overlapSameSrcPairs; + public int texelDensityBadCount; + public float texelDensityMedian; + + public int symSplitFallbackCount, symSplitTotalCount; + public int topologyIterations, topologyFixed; + public bool topologyCapHit; + + /// UV2 bbox area in [0,1] space; 1.0 = full atlas, 0.25 = quarter-filled. + public float atlasUtilization; + + // Snapshot of the result UV2 channel for post-run PNG rendering. + // Not written to CSV/JSON — consumed only by WriteArtefacts. + public Vector2[] uv2Snapshot; + public int[] trianglesSnapshot; + } + } +} diff --git a/Editor/BenchmarkRecorder.cs.meta b/Editor/BenchmarkRecorder.cs.meta new file mode 100644 index 00000000..7abe48fa --- /dev/null +++ b/Editor/BenchmarkRecorder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7514e8057c804cbfb170ea0687130cc0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BenchmarkSweep.cs b/Editor/BenchmarkSweep.cs new file mode 100644 index 00000000..7ea6abc8 --- /dev/null +++ b/Editor/BenchmarkSweep.cs @@ -0,0 +1,952 @@ +// BenchmarkSweep.cs — Post-sweep aggregator for the UV2 transfer pipeline. +// Given a list of per-cell CSVs (produced by BenchmarkRecorder during a +// LightmapTransferTool.ExecSweep run) and the matching CellConfig snapshots, +// reads each CSV, scores the cell, and writes a summary.csv + winner.json +// into a sweep_/ subdirectory of BenchmarkReports/. +// See TRANSFER_BENCHMARK.md for the metric definitions. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + /// + /// Post-run aggregator: consumes per-cell CSVs written by + /// during a sweep, picks a winner by a + /// weighted score, and writes summary.csv + winner.json into a dedicated + /// sweep_/ subdirectory. + /// + internal static class BenchmarkSweep + { + // Score weights — see Score() for the formula and rationale. + const float kWeightUtilization = 100f; + const float kPenaltySliver = -50f; + const float kPenaltyOverlap = -10f; + const float kPenaltyMs = -0.001f; + const float kPenaltyResolution = -10f; + + /// + /// Snapshot of the ctx fields that distinguish one sweep cell from + /// another. Passed alongside each per-cell CSV into the aggregator so + /// the summary rows can carry the full configuration (the CSV itself + /// doesn't track every field in a stable column). + /// + internal struct CellConfig + { + public int atlasRes; + public int shellPad; + public int borderPad; + // Stretched-shell ARAP: enabled flag + iteration count + Sander L² + // gate threshold. Replaces the old per-shell-aspect axis, which + // applied a flawed affine transform that couldn't fix bad + // parameterization (only redistributing vertices via ARAP can). + public bool arapEnabled; + public int arapIterations; + public float stretchThreshold; + } + + internal struct RunSummary + { + public CellConfig config; + public string csvPath; + public string jsonPath; + public int totalSlivers; // sum(inverted+stretched+zeroArea+oob) across target LODs + public int overlapShellPairs; // sum across target LODs + public float meanAtlasUtilization; + public long totalMs; // sum(pipeline+repack+transfer+validate) + public float score; + public bool hadFailure; + } + + /// + /// Aggregate a list of per-cell CSVs (with their matching CellConfig + /// snapshots) into a sweep_/summary.csv + winner.json under + /// BenchmarkReports/. and + /// must be the same length and aligned by + /// index. Entries with a null/missing CSV are recorded as failed cells. + /// No-op when fewer than two cells are supplied. + /// + internal static void WriteAggregateReport(List csvPaths, List configs) + => WriteAggregateReport(csvPaths, configs, sweepDir: null); + + /// + /// Same as the two-argument overload, but accepts a pre-created + /// so a long sweep can keep rewriting the + /// same summary.csv / winner.json / index.html after every successful + /// cell. When is null or empty, a new + /// sweep_<timestamp>/ folder is created under BenchmarkReports/ + /// (legacy behavior). The minimum-2-cells gate is bypassed when an + /// explicit sweepDir is provided so incremental writes work from the + /// very first completed cell. + /// + internal static void WriteAggregateReport(List csvPaths, List configs, string sweepDir) + { + if (csvPaths == null || configs == null) return; + int n = Math.Min(csvPaths.Count, configs.Count); + bool explicitDir = !string.IsNullOrEmpty(sweepDir); + if (n < 2 && !explicitDir) + { + UvtLog.Info(UvtLog.Category.Benchmark, + "[Sweep] Skipping aggregate report — fewer than 2 cells completed."); + return; + } + if (n < 1) return; + + string projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + string defaultReportsDir = Path.Combine(projectRoot, "BenchmarkReports"); + if (!explicitDir) + { + Directory.CreateDirectory(defaultReportsDir); + string sweepStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + sweepDir = Path.Combine(defaultReportsDir, $"sweep_{sweepStamp}"); + } + Directory.CreateDirectory(sweepDir); + // Use sweepDir's actual parent (not the hard-coded BenchmarkReports/ + // path) so recovery against a user-picked folder still resolves + // sibling _png thumbnail directories correctly. + string reportsDir = Directory.GetParent(sweepDir)?.FullName ?? defaultReportsDir; + + var summaries = new List(n); + for (int i = 0; i < n; i++) + { + string csvPath = csvPaths[i]; + var summary = new RunSummary { config = configs[i] }; + + if (string.IsNullOrEmpty(csvPath) || !File.Exists(csvPath)) + { + summary.hadFailure = true; + } + else + { + summary.csvPath = csvPath; + summary.jsonPath = Path.ChangeExtension(csvPath, ".json"); + try + { + summary = AggregateRun(csvPath, configs[i]); + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Failed to aggregate '{csvPath}': {ex.Message}"); + summary.hadFailure = true; + } + } + + summary.score = Score(summary); + summaries.Add(summary); + } + + // Pick the winner by score (failed runs land at -∞ and never win). + // If every cell failed, there is no valid winner — `bestIdx` would + // otherwise just default to the first failed run and produce a + // misleading winner.json. Detect that case and emit summary.csv + // only, with no winner artefact. + int bestIdx = 0; + for (int i = 1; i < summaries.Count; i++) + if (summaries[i].score > summaries[bestIdx].score) bestIdx = i; + bool anyValid = summaries.Count > 0 + && !float.IsNegativeInfinity(summaries[bestIdx].score); + + try + { + WriteSummaryCsv(Path.Combine(sweepDir, "summary.csv"), summaries, sweepDir); + if (anyValid) + { + WriteWinnerJson(Path.Combine(sweepDir, "winner.json"), summaries, bestIdx); + WriteGalleryHtml(Path.Combine(sweepDir, "index.html"), summaries, bestIdx, + BuildRecommendation(summaries, bestIdx), reportsDir); + } + else + { + // Best-effort: delete stale winner/index from earlier + // partial runs into the same sweepDir so downstream + // tooling doesn't pick them up. + try + { + string winnerPath = Path.Combine(sweepDir, "winner.json"); + if (File.Exists(winnerPath)) File.Delete(winnerPath); + string idxPath = Path.Combine(sweepDir, "index.html"); + if (File.Exists(idxPath)) File.Delete(idxPath); + } + catch { /* non-fatal */ } + } + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, $"[Sweep] Failed to write summary: {ex.Message}"); + return; + } + + if (!anyValid) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] All {summaries.Count} cell(s) failed — no winner selected. " + + $"See {Path.Combine(sweepDir, "summary.csv")} for per-cell details."); + return; + } + + var w = summaries[bestIdx]; + UvtLog.Info(UvtLog.Category.Benchmark, + $"[Sweep] Winner: res={w.config.atlasRes}, pad={w.config.shellPad}, bdr={w.config.borderPad}, " + + $"arap={(w.config.arapEnabled ? w.config.arapIterations : 0)}, " + + $"stretchThr={w.config.stretchThreshold:F2} (score={w.score:F2})"); + } + + /// + /// Weighted score: higher is better. + /// + /// score = 100 * atlasUtilization (mean across LODs) + /// - 50 * totalSlivers + /// - 10 * overlapShellPairs + /// - 0.001 * totalMs (per-millisecond penalty) + /// - 10 * log2(atlasRes / 256) (prefer lower resolution if quality equal) + /// + /// Failed runs (hadFailure=true) get -∞ so they never win the sweep. + /// + internal static float Score(RunSummary r) + { + if (r.hadFailure) return float.NegativeInfinity; + float resPenalty = 0f; + if (r.config.atlasRes > 0) + resPenalty = kPenaltyResolution * Mathf.Log(Mathf.Max(1f, r.config.atlasRes / 256f), 2f); + return kWeightUtilization * r.meanAtlasUtilization + + kPenaltySliver * r.totalSlivers + + kPenaltyOverlap * r.overlapShellPairs + + kPenaltyMs * r.totalMs + + resPenalty; + } + + /// + /// Parse a per-run CSV emitted by and + /// aggregate target-LOD metrics into a . Column + /// lookup is header-driven so the parser stays robust against future + /// column reordering or insertion. Throws on I/O errors; returns a + /// summary with hadFailure=true when the CSV has no data rows. + /// + internal static RunSummary AggregateRun(string csvPath, CellConfig cfg) + { + var summary = new RunSummary + { + config = cfg, + csvPath = csvPath, + jsonPath = Path.ChangeExtension(csvPath, ".json"), + }; + + var lines = File.ReadAllLines(csvPath); + if (lines.Length < 2) + { + summary.hadFailure = true; + return summary; + } + var header = ParseCsvRow(lines[0]); + int idx(string col) + { + for (int i = 0; i < header.Count; i++) + if (string.Equals(header[i], col, StringComparison.Ordinal)) + return i; + return -1; + } + int iIsSource = idx("isSourceLod"); + int iInverted = idx("invertedCount"); + int iStretched = idx("stretchedCount"); + int iZero = idx("zeroAreaCount"); + int iOob = idx("oobCount"); + int iOverlap = idx("overlapShellPairs"); + int iAtlasUtil = idx("atlasUtilization"); + int iPipeline = idx("pipelineMs"); + int iRepack = idx("repackMs"); + int iTransfer = idx("transferMs"); + int iValidate = idx("validateMs"); + int iShellsMatch = idx("shellsMatched"); + int iVertsXfer = idx("verticesTransferred"); + + int slivers = 0, overlap = 0; + int utilCount = 0; + float utilSum = 0f; + long totalMs = 0; + bool stageRead = false; + bool hadFailure = false; + int targetRowCount = 0; + + var inv = CultureInfo.InvariantCulture; + for (int li = 1; li < lines.Length; li++) + { + var line = lines[li]; + if (string.IsNullOrWhiteSpace(line)) continue; + var c = ParseCsvRow(line); + if (c.Count < header.Count) continue; + + bool isSource = iIsSource >= 0 && c[iIsSource] == "1"; + // Only target-LOD rows carry meaningful validation. The + // source LOD's validation report is the post-repack + // self-check and would double-count slivers in the + // score otherwise. + if (!isSource) + { + targetRowCount++; + slivers += SafeInt(c, iInverted) + SafeInt(c, iStretched) + + SafeInt(c, iZero) + SafeInt(c, iOob); + overlap += SafeInt(c, iOverlap); + + // A target-LOD row with zero shells matched AND zero + // vertices transferred means the transfer never ran (or + // ran but produced nothing). Without this guard the run + // looks "clean" (0 slivers, 0 overlaps) and can win the + // sweep despite a broken transfer. + if (iShellsMatch >= 0 && iVertsXfer >= 0 + && SafeInt(c, iShellsMatch) == 0 + && SafeInt(c, iVertsXfer) == 0) + { + hadFailure = true; + } + } + + // atlasUtilization: count every successfully parsed numeric + // value, including 0. Dropping zeros (e.g. degenerate or + // failed target outputs) used to inflate the mean by + // omitting bad rows while keeping good ones — that promoted + // partially-broken cells to winner. Skip only missing or + // unparseable values. + if (iAtlasUtil >= 0 && iAtlasUtil < c.Count + && float.TryParse(c[iAtlasUtil], + NumberStyles.Float, inv, out float util)) + { + utilSum += util; + utilCount++; + } + + // Stage timings are session-level and repeat on every row; + // read them once from the first non-empty record. pipelineMs + // is the outermost wall-clock around ExecFullPipeline and + // already contains repackMs + transferMs + validateMs, so + // summing all four would triple-count inner work and + // penalise sweep cells against the time-weighted score. For + // standalone Repack/Transfer rows (no pipeline wrapper) + // pipelineMs is 0, so fall back to the sum of inner stages. + if (!stageRead) + { + long pipe = SafeLong(c, iPipeline); + long inner = SafeLong(c, iRepack) + + SafeLong(c, iTransfer) + + SafeLong(c, iValidate); + totalMs = pipe > 0 ? pipe : inner; + stageRead = true; + } + } + + summary.totalSlivers = slivers; + summary.overlapShellPairs = overlap; + summary.meanAtlasUtilization = utilCount > 0 ? utilSum / utilCount : 0f; + summary.totalMs = totalMs; + // No target-LOD rows means transfer never produced anything — + // either the model is single-LOD or all target rows were + // dropped. Either way the failure check inside the !isSource + // branch never fires, so the run would otherwise pass scoring + // with zeros across the board. Mark it failed explicitly. + if (targetRowCount == 0) hadFailure = true; + summary.hadFailure = hadFailure; + return summary; + } + + /// + /// Minimal CSV row parser that respects double-quoted fields (with + /// "" as an escaped quote inside a quoted field). Required + /// because wraps values containing + /// commas in quotes — a naive line.Split(',') would shift + /// columns whenever a renderer name contains a comma, silently + /// corrupting the winner picked by the aggregator. + /// + static List ParseCsvRow(string line) + { + var cells = new List(); + if (line == null) { cells.Add(""); return cells; } + var sb = new StringBuilder(); + bool inQuotes = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; } + else inQuotes = false; + } + else sb.Append(c); + } + else + { + if (c == ',') { cells.Add(sb.ToString()); sb.Clear(); } + else if (c == '"' && sb.Length == 0) inQuotes = true; + else sb.Append(c); + } + } + cells.Add(sb.ToString()); + return cells; + } + + static int SafeInt (List c, int i) + => (i >= 0 && i < c.Count && int.TryParse(c[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)) ? v : 0; + static long SafeLong(List c, int i) + => (i >= 0 && i < c.Count && long.TryParse(c[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)) ? v : 0; + + internal static void WriteSummaryCsv(string path, List runs, string sweepDir) + { + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + sb.AppendLine("atlasRes,shellPad,borderPad,arapEnabled,arapIterations,stretchThreshold," + + "totalSlivers,overlapShellPairs,meanAtlasUtilization,totalMs,score,csvPath"); + foreach (var r in runs) + { + // Make csvPath relative to BenchmarkReports/ when possible — + // keeps the summary readable when the project is moved. + string rel = r.csvPath ?? ""; + if (!string.IsNullOrEmpty(rel) && !string.IsNullOrEmpty(sweepDir)) + { + string parent = Directory.GetParent(sweepDir)?.FullName; + if (!string.IsNullOrEmpty(parent) && rel.StartsWith(parent, StringComparison.Ordinal)) + rel = rel.Substring(parent.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + sb.Append(r.config.atlasRes.ToString(inv)).Append(','); + sb.Append(r.config.shellPad.ToString(inv)).Append(','); + sb.Append(r.config.borderPad.ToString(inv)).Append(','); + sb.Append(r.config.arapEnabled ? '1' : '0').Append(','); + sb.Append(r.config.arapIterations.ToString(inv)).Append(','); + sb.Append(r.config.stretchThreshold.ToString("R", inv)).Append(','); + sb.Append(r.totalSlivers.ToString(inv)).Append(','); + sb.Append(r.overlapShellPairs.ToString(inv)).Append(','); + sb.Append(r.meanAtlasUtilization.ToString("R", inv)).Append(','); + sb.Append(r.totalMs.ToString(inv)).Append(','); + sb.Append(r.score.ToString("R", inv)).Append(','); + sb.Append(Csv(rel)); + sb.AppendLine(); + } + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + UvtLog.Info(UvtLog.Category.Benchmark, $"[Sweep] summary → {path}"); + } + + internal static void WriteWinnerJson(string path, List runs, int bestIdx) + { + var inv = CultureInfo.InvariantCulture; + var w = runs[bestIdx]; + + var sb = new StringBuilder(); + sb.Append("{\n"); + sb.Append(" \"winner\": {\n"); + sb.Append(" \"atlasRes\": ").Append(w.config.atlasRes.ToString(inv)).Append(",\n"); + sb.Append(" \"shellPad\": ").Append(w.config.shellPad.ToString(inv)).Append(",\n"); + sb.Append(" \"borderPad\": ").Append(w.config.borderPad.ToString(inv)).Append(",\n"); + sb.Append(" \"arapEnabled\": ").Append(w.config.arapEnabled ? "true" : "false").Append(",\n"); + sb.Append(" \"arapIterations\": ").Append(w.config.arapIterations.ToString(inv)).Append(",\n"); + sb.Append(" \"stretchThreshold\": ").Append(w.config.stretchThreshold.ToString("R", inv)).Append(",\n"); + sb.Append(" \"score\": ").Append(JsonFloat(w.score, inv)).Append(",\n"); + sb.Append(" \"totalSlivers\": ").Append(w.totalSlivers.ToString(inv)).Append(",\n"); + sb.Append(" \"overlapShellPairs\": ").Append(w.overlapShellPairs.ToString(inv)).Append(",\n"); + sb.Append(" \"meanAtlasUtilization\": ").Append(JsonFloat(w.meanAtlasUtilization, inv)).Append(",\n"); + sb.Append(" \"totalMs\": ").Append(w.totalMs.ToString(inv)).Append(",\n"); + sb.Append(" \"csvPath\": ").Append(JsonString(w.csvPath ?? "")).Append("\n"); + sb.Append(" },\n"); + + sb.Append(" \"recommendation\": ").Append(JsonString(BuildRecommendation(runs, bestIdx))).Append(",\n"); + + sb.Append(" \"scoring\": {\n"); + sb.Append(" \"atlasUtilizationWeight\": ").Append(kWeightUtilization.ToString("R", inv)).Append(",\n"); + sb.Append(" \"sliverPenalty\": ").Append(kPenaltySliver.ToString("R", inv)).Append(",\n"); + sb.Append(" \"overlapPenalty\": ").Append(kPenaltyOverlap.ToString("R", inv)).Append(",\n"); + sb.Append(" \"msPenalty\": ").Append(kPenaltyMs.ToString("R", inv)).Append(",\n"); + sb.Append(" \"resolutionPenalty\": ").Append(kPenaltyResolution.ToString("R", inv)).Append("\n"); + sb.Append(" },\n"); + + sb.Append(" \"runs\": [\n"); + for (int i = 0; i < runs.Count; i++) + { + var r = runs[i]; + sb.Append(" {"); + sb.Append("\"atlasRes\": ").Append(r.config.atlasRes.ToString(inv)).Append(", "); + sb.Append("\"shellPad\": ").Append(r.config.shellPad.ToString(inv)).Append(", "); + sb.Append("\"borderPad\": ").Append(r.config.borderPad.ToString(inv)).Append(", "); + sb.Append("\"arapEnabled\": ").Append(r.config.arapEnabled ? "true" : "false").Append(", "); + sb.Append("\"arapIterations\": ").Append(r.config.arapIterations.ToString(inv)).Append(", "); + sb.Append("\"stretchThreshold\": ").Append(r.config.stretchThreshold.ToString("R", inv)).Append(", "); + sb.Append("\"totalSlivers\": ").Append(r.totalSlivers.ToString(inv)).Append(", "); + sb.Append("\"overlapShellPairs\": ").Append(r.overlapShellPairs.ToString(inv)).Append(", "); + sb.Append("\"meanAtlasUtilization\": ").Append(JsonFloat(r.meanAtlasUtilization, inv)).Append(", "); + sb.Append("\"totalMs\": ").Append(r.totalMs.ToString(inv)).Append(", "); + sb.Append("\"score\": ").Append(JsonFloat(r.score, inv)).Append(", "); + sb.Append("\"hadFailure\": ").Append(r.hadFailure ? "true" : "false").Append(", "); + sb.Append("\"csvPath\": ").Append(JsonString(r.csvPath ?? "")); + sb.Append("}"); + if (i < runs.Count - 1) sb.Append(','); + sb.Append('\n'); + } + sb.Append(" ]\n"); + sb.Append("}\n"); + + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + UvtLog.Info(UvtLog.Category.Benchmark, $"[Sweep] winner → {path}"); + } + + /// + /// Emit a self-contained index.html gallery into the sweep + /// directory. One sortable row per run with per-LOD UV2 thumbnails + /// linking back into BenchmarkReports/<csvBase>_png/. The + /// winner row is highlighted via a winner CSS class. The file + /// is UTF-8 (no BOM) and uses no external dependencies. + /// + internal static void WriteGalleryHtml(string path, List runs, int bestIdx, + string recommendation, string benchmarkReportsRoot) + { + var inv = CultureInfo.InvariantCulture; + string sweepName = Path.GetFileName(Path.GetDirectoryName(path) ?? ""); + string isoStamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", inv); + + string winnerLabel = "—"; + string winnerScore = "—"; + if (bestIdx >= 0 && bestIdx < runs.Count) + { + var w = runs[bestIdx]; + winnerLabel = $"res={w.config.atlasRes}, pad={w.config.shellPad}, bdr={w.config.borderPad}, " + + $"arap={(w.config.arapEnabled ? w.config.arapIterations : 0)}, " + + $"stretchThr={w.config.stretchThreshold.ToString("F2", inv)}"; + winnerScore = w.score.ToString("F2", inv); + } + + var sb = new StringBuilder(); + sb.Append("\n"); + sb.Append("\n\n"); + sb.Append(" \n"); + sb.Append(" Sweep: ").Append(HtmlEscape(sweepName)) + .Append(" (").Append(runs.Count.ToString(inv)).Append(" cells)\n"); + sb.Append(" \n"); + sb.Append("\n\n"); + sb.Append("

Sweep: ").Append(HtmlEscape(sweepName)).Append("

\n"); + sb.Append("

Generated: ").Append(HtmlEscape(isoStamp)) + .Append(". Runs: ").Append(runs.Count.ToString(inv)) + .Append(". Best: ").Append(HtmlEscape(winnerLabel)) + .Append(" (score ").Append(HtmlEscape(winnerScore)).Append(").

\n"); + + sb.Append("

Recommendation

\n"); + sb.Append("

").Append(HtmlEscape(recommendation ?? "")).Append("

\n"); + + sb.Append("

Per-run metrics

\n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + + for (int i = 0; i < runs.Count; i++) + { + var r = runs[i]; + string cls = (i == bestIdx) ? " class=\"winner\"" : ""; + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + } + + sb.Append(" \n"); + sb.Append("
#atlasResshellPadborderPadarapEnabledarapItersstretchThrsliversoverlapatlas%msscoreUV2 thumbs
").Append((i + 1).ToString(inv)).Append("").Append(r.config.atlasRes.ToString(inv)).Append("").Append(r.config.shellPad.ToString(inv)).Append("").Append(r.config.borderPad.ToString(inv)).Append("").Append(r.config.arapEnabled ? "1" : "0").Append("").Append(r.config.arapIterations.ToString(inv)).Append("").Append(r.config.stretchThreshold.ToString("F2", inv)).Append("").Append(r.totalSlivers.ToString(inv)).Append("").Append(r.overlapShellPairs.ToString(inv)).Append("").Append((r.meanAtlasUtilization * 100f).ToString("F2", inv)).Append("").Append(r.totalMs.ToString(inv)).Append("").Append(r.score.ToString("F2", inv)).Append("").Append(BuildThumbsCell(r.csvPath, benchmarkReportsRoot)).Append("
\n"); + + sb.Append(" \n"); + sb.Append("\n\n"); + + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + UvtLog.Info(UvtLog.Category.Benchmark, $"[Sweep] gallery → {path}"); + } + + /// + /// Resolve the sibling <csvBase>_png/ directory for a + /// run's CSV and emit the inner HTML for the "UV2 thumbs" cell — + /// one anchored thumbnail per PNG, sorted by file name so LOD0 lands + /// before LOD1, LOD2, … Returns <em>(no PNG)</em> + /// when the directory is missing or empty. + /// + static string BuildThumbsCell(string csvPath, string benchmarkReportsRoot) + { + if (string.IsNullOrEmpty(csvPath)) return "(no PNG)"; + string csvBase = Path.GetFileNameWithoutExtension(csvPath); + if (string.IsNullOrEmpty(csvBase) || string.IsNullOrEmpty(benchmarkReportsRoot)) + return "(no PNG)"; + + string pngDirName = csvBase + "_png"; + string pngDirAbs = Path.Combine(benchmarkReportsRoot, pngDirName); + if (!Directory.Exists(pngDirAbs)) return "(no PNG)"; + + string[] pngs; + try { pngs = Directory.GetFiles(pngDirAbs, "*.png"); } + catch { return "(no PNG)"; } + if (pngs == null || pngs.Length == 0) return "(no PNG)"; + + Array.Sort(pngs, StringComparer.Ordinal); + + var sb = new StringBuilder(); + sb.Append("
"); + foreach (string pngAbs in pngs) + { + string fileName = Path.GetFileName(pngAbs); + // Sweep dir is sibling of the PNG dir under BenchmarkReports/, + // so "../_png/" is the stable relative link. + string rel = "../" + pngDirName + "/" + fileName; + string label = ExtractLodLabel(fileName); + sb.Append("
"); + sb.Append(""); + sb.Append("\"")"); + sb.Append(""); + sb.Append("
").Append(HtmlEscape(label)).Append("
"); + sb.Append("
"); + } + sb.Append("
"); + return sb.ToString(); + } + + /// + /// Pull the "LOD<N>" token out of a PNG file name like + /// Wooden_Box_Long_LOD0_uv2.png. Falls back to the whole base + /// name when the convention doesn't match. + /// + static string ExtractLodLabel(string fileName) + { + string baseName = Path.GetFileNameWithoutExtension(fileName); + if (string.IsNullOrEmpty(baseName)) return fileName; + int lodIdx = baseName.IndexOf("LOD", StringComparison.Ordinal); + if (lodIdx < 0) return baseName; + int end = lodIdx + 3; + while (end < baseName.Length && char.IsDigit(baseName[end])) end++; + if (end == lodIdx + 3) return baseName; + return baseName.Substring(lodIdx, end - lodIdx); + } + + /// + /// Build a short English summary contrasting the winner against the + /// alternatives along each grid axis. Reads as: "use this resolution, + /// stretched-shell ARAP helped/didn't at this threshold". + /// + static string BuildRecommendation(List runs, int bestIdx) + { + var w = runs[bestIdx]; + var sb = new StringBuilder(); + sb.Append("Use ").Append(w.config.atlasRes).Append(" resolution"); + sb.Append(", shellPad=").Append(w.config.shellPad); + sb.Append(", borderPad=").Append(w.config.borderPad); + sb.Append(" with stretched-shell ARAP "); + sb.Append(w.config.arapEnabled + ? $"ON ({w.config.arapIterations} iters, L²>{w.config.stretchThreshold.ToString("F2", CultureInfo.InvariantCulture)})" + : "OFF"); + sb.Append('.'); + + // Compare against the same config with ARAP toggled. + bool? arapHelped = ComparePair(runs, w); + if (arapHelped.HasValue) + sb.Append(" Stretched-shell ARAP ") + .Append(arapHelped.Value ? "improved" : "did not improve") + .Append(" results on this asset."); + return sb.ToString(); + } + + /// + /// Looks up the run that shares the winner's resolution/padding and + /// stretch threshold but has ARAP toggled the other way, then reports + /// whether the winner's enabled flag out-scored its disabled counterpart. + /// Returns null if the pair is not in the grid. + /// + static bool? ComparePair(List runs, RunSummary w) + { + foreach (var r in runs) + { + if (r.config.atlasRes != w.config.atlasRes) continue; + if (r.config.shellPad != w.config.shellPad) continue; + if (r.config.borderPad != w.config.borderPad) continue; + // Stretch threshold is irrelevant when ARAP is off — match the + // winner's threshold on the ON side and ignore it on the OFF + // side. Either way, the pair is "this config with ARAP toggled". + if (w.config.arapEnabled && r.config.arapEnabled && + !Mathf.Approximately(r.config.stretchThreshold, w.config.stretchThreshold)) + continue; + if (r.config.arapEnabled == w.config.arapEnabled) continue; + + bool winnerOn = w.config.arapEnabled; + bool winnerBetter = w.score > r.score; + return winnerOn ? winnerBetter : !winnerBetter; + } + return null; + } + + /// + /// Recovery utility: reconstructs a sweep_recovered_<timestamp>/ + /// report from the per-cell CSVs already present in a + /// BenchmarkReports/ directory after a mid-sweep Unity crash + /// destroyed the in-memory aligned lists. The method: + /// + /// scans non-recursively for *.csv files at the root, + /// parses res{R}_pad{S}_bdr{B}_psa{P}_arap{A} tokens from + /// each filename and skips any CSV that doesn't match (so existing + /// sweep_*/summary.csv files are ignored), + /// clusters the matched CSVs by file mtime — each cluster has + /// gaps no larger than kRecoveryGapSeconds between adjacent + /// files when sorted by time, + /// picks the largest cluster as the crashed sweep, + /// writes summary.csv / winner.json / index.html into a new + /// sweep_recovered_<UTCstamp>/ sibling folder. + /// + /// Returns the absolute path of the created sweep folder, or null on + /// failure (no matching CSVs, I/O error, etc.). + /// + internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) + { + if (string.IsNullOrEmpty(benchmarkReportsRoot) || !Directory.Exists(benchmarkReportsRoot)) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: directory not found: {benchmarkReportsRoot}"); + return null; + } + + string[] csvFiles; + try { csvFiles = Directory.GetFiles(benchmarkReportsRoot, "*.csv", SearchOption.TopDirectoryOnly); } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: failed to enumerate CSVs: {ex.Message}"); + return null; + } + if (csvFiles == null || csvFiles.Length == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: no CSVs found in {benchmarkReportsRoot}"); + return null; + } + + // The stretch threshold is encoded as "p<2-digit>" in the + // label (e.g. 1.50 → "1p50") because Sanitize() collapses '.' to + // '_'. We split it back into a float here. The optional + // `(?:_asp[01])?` tail keeps old per-cell CSVs from the era of the + // removed global-aspect normalize pass parseable. + var rx = new System.Text.RegularExpressions.Regex( + @"_sweep_res(\d+)_pad(\d+)_bdr(\d+)_arap(\d+)_stretch(\d+)p(\d+)(?:_asp[01])?_", + System.Text.RegularExpressions.RegexOptions.Compiled); + + var matched = new List<(string path, CellConfig cfg, DateTime mtime)>(); + foreach (string csv in csvFiles) + { + string name = Path.GetFileName(csv); + var m = rx.Match(name); + if (!m.Success) continue; + if (!int.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int res)) continue; + if (!int.TryParse(m.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pad)) continue; + if (!int.TryParse(m.Groups[3].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int bdr)) continue; + if (!int.TryParse(m.Groups[4].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int arapIters)) continue; + // Reassemble float "p" → . + string stretchStr = m.Groups[5].Value + "." + m.Groups[6].Value; + if (!float.TryParse(stretchStr, NumberStyles.Float, CultureInfo.InvariantCulture, out float stretchThr)) + stretchThr = 1.5f; + + DateTime mtime; + try { mtime = File.GetLastWriteTimeUtc(csv); } + catch { mtime = DateTime.UtcNow; } + + matched.Add((csv, new CellConfig + { + atlasRes = res, + shellPad = pad, + borderPad = bdr, + arapEnabled = arapIters > 0, + arapIterations = arapIters, + stretchThreshold = stretchThr, + }, mtime)); + } + + if (matched.Count == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: no CSV matched the sweep_resR_padS_bdrB_arapA_stretchT pattern in {benchmarkReportsRoot}"); + return null; + } + + // Cluster by mtime gaps. Files belonging to a single sweep are + // written back-to-back; a gap larger than kRecoveryGapSeconds is + // treated as a sweep boundary. + matched.Sort((a, b) => a.mtime.CompareTo(b.mtime)); + var clusters = new List>(); + var current = new List<(string path, CellConfig cfg, DateTime mtime)> { matched[0] }; + for (int i = 1; i < matched.Count; i++) + { + double gap = (matched[i].mtime - matched[i - 1].mtime).TotalSeconds; + if (gap > kRecoveryGapSeconds) + { + clusters.Add(current); + current = new List<(string path, CellConfig cfg, DateTime mtime)>(); + } + current.Add(matched[i]); + } + clusters.Add(current); + + // Largest cluster = the sweep the user wants to recover. + int bestC = 0; + for (int i = 1; i < clusters.Count; i++) + if (clusters[i].Count > clusters[bestC].Count) bestC = i; + var chosen = clusters[bestC]; + + var csvPaths = new List(chosen.Count); + var configs = new List(chosen.Count); + foreach (var t in chosen) { csvPaths.Add(t.path); configs.Add(t.cfg); } + + string recoveryStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + string sweepDir = Path.Combine(benchmarkReportsRoot, $"sweep_recovered_{recoveryStamp}"); + try { Directory.CreateDirectory(sweepDir); } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: failed to create {sweepDir}: {ex.Message}"); + return null; + } + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: rebuilding {chosen.Count} cells (from {clusters.Count} cluster(s), " + + $"largest selected) into {sweepDir}"); + + try + { + WriteAggregateReport(csvPaths, configs, sweepDir); + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Recovery: aggregate write failed: {ex.Message}"); + return null; + } + return sweepDir; + } + + // Files belonging to a single sweep are written back-to-back by the + // pipeline. 5 minutes is generous: even a slow cell finishes well under + // that, but it's large enough to absorb GC pauses, AssetDatabase + // refreshes, or progress-bar idle gaps between cells. + const double kRecoveryGapSeconds = 300.0; + + // ── Helpers ── + static string Csv(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + bool needQuote = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; + if (!needQuote) return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; + } + + /// + /// Minimal HTML escape covering the four characters that can break + /// attribute values or text content in our generated index.html + /// (&, <, >, "). Sufficient because + /// the gallery is self-contained and we never embed user-supplied + /// scripts. + /// + static string HtmlEscape(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + var sb = new StringBuilder(s.Length); + foreach (char c in s) + { + switch (c) + { + case '&': sb.Append("&"); break; + case '<': sb.Append("<"); break; + case '>': sb.Append(">"); break; + case '"': sb.Append("""); break; + default: sb.Append(c); break; + } + } + return sb.ToString(); + } + + /// + /// Format a float for JSON output. NaN / ±Infinity are + /// not valid JSON numbers, so emit "null" for those cases — + /// failed sweep runs land on float.NegativeInfinity via + /// , and a downstream parser would otherwise fail. + /// + static string JsonFloat(float v, CultureInfo inv) + => (float.IsNaN(v) || float.IsInfinity(v)) ? "null" : v.ToString("R", inv); + + static string JsonString(string s) + { + var sb = new StringBuilder(); + sb.Append('"'); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) sb.Append($"\\u{(int)c:X4}"); + else sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + } +} diff --git a/Editor/BenchmarkSweep.cs.meta b/Editor/BenchmarkSweep.cs.meta new file mode 100644 index 00000000..5fb1067f --- /dev/null +++ b/Editor/BenchmarkSweep.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b705afdb1a243649fb6dc8ca0697751 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/FbxMetricsExporter.cs b/Editor/FbxMetricsExporter.cs new file mode 100644 index 00000000..b8022285 --- /dev/null +++ b/Editor/FbxMetricsExporter.cs @@ -0,0 +1,401 @@ +// FbxMetricsExporter.cs — Menu-driven export of source-FBX characterization metrics +// + UV0/UV2 snapshot PNGs. Run once per test suite to pair with BenchmarkRecorder +// CSVs so the sweep numbers can be interpreted against each model's baseline. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + public static class FbxMetricsExporter + { + [MenuItem("Mesh Lab/Export FBX Metrics (Selected Assets)")] + public static void ExportForSelection() + { + var fbxPaths = Selection.assetGUIDs + .Select(AssetDatabase.GUIDToAssetPath) + .Where(p => !string.IsNullOrEmpty(p) && + p.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase)) + .Distinct() + .ToList(); + + if (fbxPaths.Count == 0) + { + EditorUtility.DisplayDialog("Export FBX Metrics", + "No .fbx assets selected in the Project window.", "OK"); + return; + } + Run(fbxPaths); + } + + [MenuItem("Mesh Lab/Export FBX Metrics (Scene LODGroup)")] + public static void ExportForSceneLodGroup() + { + var lod = Selection.activeGameObject != null + ? Selection.activeGameObject.GetComponentInParent() + : null; + if (lod == null) + { + EditorUtility.DisplayDialog("Export FBX Metrics", + "Select a GameObject under a LODGroup in the Hierarchy.", "OK"); + return; + } + + var renderers = new List(); + foreach (var level in lod.GetLODs()) + foreach (var r in level.renderers ?? new Renderer[0]) + if (r != null) renderers.Add(r); + + string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + string outDir = PrepareOutDir(stamp); + var rows = new List(); + int lodIdx = 0; + foreach (var level in lod.GetLODs()) + { + foreach (var r in level.renderers ?? new Renderer[0]) + { + if (r == null) continue; + var mf = r.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + var row = AnalyzeMesh(lod.name, lod.name, lodIdx, r.name, mf.sharedMesh); + rows.Add(row); + UvPngWriter.Render(Path.Combine(outDir, "png", + $"{Sanitize(lod.name)}_LOD{lodIdx}_{Sanitize(r.name)}_uv0.png"), + mf.sharedMesh, 0); + if (row.hasUv2) + UvPngWriter.Render(Path.Combine(outDir, "png", + $"{Sanitize(lod.name)}_LOD{lodIdx}_{Sanitize(r.name)}_uv2.png"), + mf.sharedMesh, 1); + } + lodIdx++; + } + WriteCsv(outDir, stamp, rows); + UvtLog.Info(UvtLog.Category.Benchmark, + $"FBX metrics: {rows.Count} row(s) → {outDir}"); + EditorUtility.RevealInFinder(outDir); + } + + static void Run(List fbxPaths) + { + string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + string outDir = PrepareOutDir(stamp); + string pngDir = Path.Combine(outDir, "png"); + Directory.CreateDirectory(pngDir); + + var rows = new List(); + int total = fbxPaths.Count; + for (int i = 0; i < total; i++) + { + string path = fbxPaths[i]; + if (EditorUtility.DisplayCancelableProgressBar("Export FBX Metrics", + $"{i + 1}/{total}: {Path.GetFileName(path)}", + (float)i / Mathf.Max(1, total))) + break; + + var root = AssetDatabase.LoadAssetAtPath(path); + if (root == null) continue; + string modelName = Path.GetFileNameWithoutExtension(path); + + // Collect renderers (either under a LODGroup or loose). + var lodGroups = root.GetComponentsInChildren(true); + if (lodGroups != null && lodGroups.Length > 0) + { + foreach (var lg in lodGroups) + { + int lodIdx = 0; + foreach (var level in lg.GetLODs()) + { + foreach (var r in level.renderers ?? new Renderer[0]) + AnalyzeAndDump(r, modelName, lg.name, lodIdx, rows, pngDir); + lodIdx++; + } + } + } + else + { + foreach (var r in root.GetComponentsInChildren(true)) + AnalyzeAndDump(r, modelName, root.name, 0, rows, pngDir); + } + } + EditorUtility.ClearProgressBar(); + + WriteCsv(outDir, stamp, rows); + UvtLog.Info(UvtLog.Category.Benchmark, + $"FBX metrics: {rows.Count} row(s) from {fbxPaths.Count} FBX → {outDir}"); + EditorUtility.RevealInFinder(outDir); + } + + static void AnalyzeAndDump(Renderer r, string modelName, string lodGroupName, + int lodIdx, List rows, string pngDir) + { + if (r == null) return; + var mf = r.GetComponent(); + if (mf == null || mf.sharedMesh == null) return; + + var mesh = mf.sharedMesh; + var row = AnalyzeMesh(modelName, lodGroupName, lodIdx, r.name, mesh); + rows.Add(row); + + UvPngWriter.Render(Path.Combine(pngDir, + $"{Sanitize(modelName)}_{Sanitize(lodGroupName)}_LOD{lodIdx}_{Sanitize(r.name)}_uv0.png"), + mesh, 0); + if (row.hasUv2) + UvPngWriter.Render(Path.Combine(pngDir, + $"{Sanitize(modelName)}_{Sanitize(lodGroupName)}_LOD{lodIdx}_{Sanitize(r.name)}_uv2.png"), + mesh, 1); + } + + // ── Analysis ─────────────────────────────────────────────────── + + class Row + { + public string modelName, lodGroupName, rendererName; + public int lodIndex; + public int vertexCount, triangleCount, submeshCount; + public Vector3 boundsSize; + public float avgEdgeLengthWorld; + public int uv0ShellCount; + public float uv0TotalCoverage; + public float uv0MaxShellArea; + public float uv0MeanShellArea; + public int uv0AabbOverlapPairs; + public int uv0OobVertexCount; + public int disconnectedGeomIslands; + public bool hasUv2; + public int uv2ShellCount; + public int uv2AabbOverlapPairs; + public int uv2OobVertexCount; + public int mirrorPairsDetected; + public float shellAreaStdDev; + } + + static Row AnalyzeMesh(string modelName, string lodGroupName, int lodIdx, string rendererName, Mesh mesh) + { + var row = new Row + { + modelName = modelName, + lodGroupName = lodGroupName, + rendererName = rendererName, + lodIndex = lodIdx, + vertexCount = mesh.vertexCount, + triangleCount = mesh.triangles.Length / 3, + submeshCount = mesh.subMeshCount, + boundsSize = mesh.bounds.size, + }; + + var verts = mesh.vertices; + var tris = mesh.triangles; + row.avgEdgeLengthWorld = AvgEdgeLength(verts, tris); + row.disconnectedGeomIslands = CountGeometryIslands(tris, verts.Length); + + // UV0 analysis + var uv0List = new List(); + mesh.GetUVs(0, uv0List); + if (uv0List.Count > 0 && tris.Length >= 3) + { + var uv0 = uv0List.ToArray(); + List shells = null; + try { shells = UvShellExtractor.Extract(uv0, tris); } catch { } + if (shells != null) + { + row.uv0ShellCount = shells.Count; + float total = 0f, max = 0f; + foreach (var sh in shells) + { + float a = Mathf.Max(0f, sh.bboxArea); + total += a; + if (a > max) max = a; + } + row.uv0TotalCoverage = total; + row.uv0MaxShellArea = max; + row.uv0MeanShellArea = shells.Count > 0 ? total / shells.Count : 0f; + row.uv0AabbOverlapPairs = UvShellExtractor.CountAabbOverlaps(shells); + row.shellAreaStdDev = StdDev(shells.Select(s => Mathf.Max(0f, s.bboxArea))); + row.mirrorPairsDetected = CountMirrorPairs(shells); + } + row.uv0OobVertexCount = CountOob(uv0); + } + + // UV2 analysis (if present) + var uv2List = new List(); + mesh.GetUVs(1, uv2List); + if (uv2List.Count > 0 && uv2List.Count == mesh.vertexCount) + { + row.hasUv2 = true; + var uv2 = uv2List.ToArray(); + List shells = null; + try { shells = UvShellExtractor.Extract(uv2, tris); } catch { } + if (shells != null) + { + row.uv2ShellCount = shells.Count; + row.uv2AabbOverlapPairs = UvShellExtractor.CountAabbOverlaps(shells); + } + row.uv2OobVertexCount = CountOob(uv2); + } + + return row; + } + + static float AvgEdgeLength(Vector3[] v, int[] t) + { + if (v == null || t == null || t.Length < 3) return 0f; + double sum = 0; long n = 0; + for (int f = 0; f < t.Length; f += 3) + { + int a = t[f], b = t[f + 1], c = t[f + 2]; + if (a >= v.Length || b >= v.Length || c >= v.Length) continue; + sum += Vector3.Distance(v[a], v[b]); n++; + sum += Vector3.Distance(v[b], v[c]); n++; + sum += Vector3.Distance(v[c], v[a]); n++; + } + return n > 0 ? (float)(sum / n) : 0f; + } + + static int CountOob(Vector2[] uv) + { + int c = 0; + for (int i = 0; i < uv.Length; i++) + if (uv[i].x < -0.001f || uv[i].x > 1.001f || + uv[i].y < -0.001f || uv[i].y > 1.001f) c++; + return c; + } + + // Geometry-island count via union-find on shared vertex indices + static int CountGeometryIslands(int[] tris, int vertCount) + { + if (tris == null || tris.Length < 3 || vertCount <= 0) return 0; + var parent = new int[vertCount]; + for (int i = 0; i < vertCount; i++) parent[i] = i; + int Find(int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; } + void Union(int a, int b) { int ra = Find(a), rb = Find(b); if (ra != rb) parent[ra] = rb; } + for (int f = 0; f < tris.Length; f += 3) + { + int a = tris[f], b = tris[f + 1], c = tris[f + 2]; + if (a >= vertCount || b >= vertCount || c >= vertCount) continue; + Union(a, b); Union(b, c); + } + var roots = new HashSet(); + for (int f = 0; f < tris.Length; f += 3) + { + int a = tris[f]; + if (a < vertCount) roots.Add(Find(a)); + } + return roots.Count; + } + + static float StdDev(IEnumerable xs) + { + var arr = xs as float[] ?? xs.ToArray(); + if (arr.Length == 0) return 0f; + double mean = 0; foreach (var x in arr) mean += x; mean /= arr.Length; + double sq = 0; foreach (var x in arr) { double d = x - mean; sq += d * d; } + return (float)Math.Sqrt(sq / arr.Length); + } + + // Rough mirror-pair count: pairs of shells with similar area + boundaryLength + // but centroid.x reflected across 0.5. Heuristic only, matches SymSplit legacy. + static int CountMirrorPairs(List shells) + { + if (shells == null || shells.Count < 2) return 0; + int pairs = 0; + var used = new bool[shells.Count]; + for (int i = 0; i < shells.Count; i++) + { + if (used[i]) continue; + var a = shells[i]; + float aCx = (a.boundsMin.x + a.boundsMax.x) * 0.5f; + for (int j = i + 1; j < shells.Count; j++) + { + if (used[j]) continue; + var b = shells[j]; + float bCx = (b.boundsMin.x + b.boundsMax.x) * 0.5f; + float bCyDy = Mathf.Abs( + (a.boundsMin.y + a.boundsMax.y) * 0.5f - + (b.boundsMin.y + b.boundsMax.y) * 0.5f); + bool mirroredX = Mathf.Abs((aCx + bCx) - 1f) < 0.05f && bCyDy < 0.05f; + if (!mirroredX) continue; + float areaRel = Mathf.Abs(a.bboxArea - b.bboxArea) / + Mathf.Max(1e-6f, Mathf.Max(a.bboxArea, b.bboxArea)); + if (areaRel > 0.1f) continue; + pairs++; used[i] = used[j] = true; break; + } + } + return pairs; + } + + // ── Output ───────────────────────────────────────────────────── + + static string PrepareOutDir(string stamp) + { + string root = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + string dir = Path.Combine(root, "BenchmarkReports", $"FbxMetrics_{stamp}"); + Directory.CreateDirectory(dir); + Directory.CreateDirectory(Path.Combine(dir, "png")); + return dir; + } + + static void WriteCsv(string dir, string stamp, List rows) + { + var sb = new StringBuilder(); + sb.AppendLine("model,lodGroup,renderer,lodIndex,vertexCount,triangleCount,submeshCount," + + "boundsSizeX,boundsSizeY,boundsSizeZ,avgEdgeLengthWorld,disconnectedGeomIslands," + + "uv0ShellCount,uv0TotalCoverage,uv0MaxShellArea,uv0MeanShellArea,uv0ShellAreaStdDev," + + "uv0AabbOverlapPairs,uv0OobVertexCount,mirrorPairsDetected," + + "hasUv2,uv2ShellCount,uv2AabbOverlapPairs,uv2OobVertexCount"); + var inv = CultureInfo.InvariantCulture; + foreach (var r in rows) + { + sb.Append(Csv(r.modelName)).Append(',') + .Append(Csv(r.lodGroupName)).Append(',') + .Append(Csv(r.rendererName)).Append(',') + .Append(r.lodIndex).Append(',') + .Append(r.vertexCount).Append(',') + .Append(r.triangleCount).Append(',') + .Append(r.submeshCount).Append(',') + .Append(r.boundsSize.x.ToString("R", inv)).Append(',') + .Append(r.boundsSize.y.ToString("R", inv)).Append(',') + .Append(r.boundsSize.z.ToString("R", inv)).Append(',') + .Append(r.avgEdgeLengthWorld.ToString("R", inv)).Append(',') + .Append(r.disconnectedGeomIslands).Append(',') + .Append(r.uv0ShellCount).Append(',') + .Append(r.uv0TotalCoverage.ToString("R", inv)).Append(',') + .Append(r.uv0MaxShellArea.ToString("R", inv)).Append(',') + .Append(r.uv0MeanShellArea.ToString("R", inv)).Append(',') + .Append(r.shellAreaStdDev.ToString("R", inv)).Append(',') + .Append(r.uv0AabbOverlapPairs).Append(',') + .Append(r.uv0OobVertexCount).Append(',') + .Append(r.mirrorPairsDetected).Append(',') + .Append(r.hasUv2 ? 1 : 0).Append(',') + .Append(r.uv2ShellCount).Append(',') + .Append(r.uv2AabbOverlapPairs).Append(',') + .Append(r.uv2OobVertexCount); + sb.AppendLine(); + } + File.WriteAllText(Path.Combine(dir, $"FbxMetrics_{stamp}.csv"), sb.ToString(), Encoding.UTF8); + } + + static string Csv(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + bool q = s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; + return q ? "\"" + s.Replace("\"", "\"\"") + "\"" : s; + } + + static string Sanitize(string s) + { + if (string.IsNullOrEmpty(s)) return "x"; + var sb = new StringBuilder(s.Length); + foreach (char c in s) + sb.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '_'); + return sb.ToString(); + } + + // PNG rendering delegated to UvPngWriter. + } +} diff --git a/Editor/FbxMetricsExporter.cs.meta b/Editor/FbxMetricsExporter.cs.meta new file mode 100644 index 00000000..bbed0ef0 --- /dev/null +++ b/Editor/FbxMetricsExporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b3593fbe99e45f4a1a660d1f1db8c9d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Framework/IUvTool.cs b/Editor/Framework/IUvTool.cs index 8286f5c3..9986bad7 100644 --- a/Editor/Framework/IUvTool.cs +++ b/Editor/Framework/IUvTool.cs @@ -65,4 +65,15 @@ public interface IUvTool /// Set by the hub. Invoke to trigger an editor window repaint. System.Action RequestRepaint { set; } } + + /// + /// Opt-in marker for tools that want a right-side sidebar in addition to + /// the standard left sidebar. The hub renders this sidebar to the right + /// of the canvas with its own resize handle. Tools that don't implement + /// this interface render with the canvas spanning the remaining width. + /// + public interface IUvToolRightSidebar + { + void OnDrawRightSidebar(); + } } diff --git a/Editor/Framework/MeshEntry.cs b/Editor/Framework/MeshEntry.cs index dc302454..c3175747 100644 --- a/Editor/Framework/MeshEntry.cs +++ b/Editor/Framework/MeshEntry.cs @@ -45,6 +45,12 @@ public class MeshEntry /// Destroyed on pipeline reset or window close. /// public Mesh repackedMesh; + /// + /// Resolved xatlas dimensions used to produce . + /// Zero when UV2 came from an existing asset rather than this repack run. + /// + public uint repackedAtlasWidth; + public uint repackedAtlasHeight; /// /// UV2-transferred mesh for target LODs. Null until the Transfer step runs. diff --git a/Editor/Framework/MeshGroupColors.cs b/Editor/Framework/MeshGroupColors.cs new file mode 100644 index 00000000..bdb63414 --- /dev/null +++ b/Editor/Framework/MeshGroupColors.cs @@ -0,0 +1,54 @@ +// MeshGroupColors.cs — deterministic per-mesh-group palette. +// Both the Prefab Builder hierarchy and the UV2 Transfer per-mesh repack +// panel surface "mesh groups" identified by a stripped base-name key +// (UvToolContext.ExtractGroupKey). Routing both surfaces through the same +// hash → palette lookup means a given group always gets the same colour +// across the whole tool, so the user can visually correlate the group +// they're picking in the hierarchy with the group they're packing in +// UV2 Transfer. + +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class MeshGroupColors + { + // Twelve hand-tuned hues that read distinctly on the dark editor + // background and don't clash with the status colours we already + // reserve for fresh / stale / pending-delete row tints (orange, + // amber, red). + static readonly Color[] palette = + { + new Color(0.55f, 0.85f, 1.00f), // sky + new Color(0.65f, 1.00f, 0.65f), // mint + new Color(0.85f, 0.65f, 1.00f), // lavender + new Color(0.55f, 0.85f, 0.85f), // teal + new Color(1.00f, 0.85f, 0.55f), // sand + new Color(0.85f, 1.00f, 0.55f), // lime + new Color(0.65f, 0.85f, 1.00f), // periwinkle + new Color(1.00f, 0.65f, 0.95f), // bubble + new Color(0.65f, 1.00f, 0.95f), // aqua + new Color(0.95f, 0.95f, 0.55f), // butter + new Color(1.00f, 0.75f, 0.65f), // coral + new Color(0.75f, 0.95f, 0.75f), // sage + }; + + /// + /// Stable colour for a mesh-group key (e.g. "Stove_Base"). Same + /// input always produces the same output — no per-session state, + /// no hashing surprises across reloads. Empty / null keys fall + /// back to a neutral grey so the caller can still draw the swatch + /// without branching. + /// + public static Color GetColor(string groupKey) + { + if (string.IsNullOrEmpty(groupKey)) + return new Color(0.6f, 0.6f, 0.6f); + int hash = 17; + for (int i = 0; i < groupKey.Length; i++) + hash = unchecked(hash * 31 + groupKey[i]); + int idx = (hash & int.MaxValue) % palette.Length; + return palette[idx]; + } + } +} diff --git a/Editor/Framework/MeshGroupColors.cs.meta b/Editor/Framework/MeshGroupColors.cs.meta new file mode 100644 index 00000000..afc6899d --- /dev/null +++ b/Editor/Framework/MeshGroupColors.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a6ce99c0fd2942ee89ad8abdf23248e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Framework/UvCanvasView.cs b/Editor/Framework/UvCanvasView.cs index 90835ddb..d844d0c8 100644 --- a/Editor/Framework/UvCanvasView.cs +++ b/Editor/Framework/UvCanvasView.cs @@ -74,6 +74,13 @@ public class ShellDebugHit public bool ShowBorder = true; public float FillAlpha = 0.25f; + /// + /// When non-zero, validation fill/overlay draws only triangles whose TriIssue + /// intersects this mask. When zero (default) every triangle is drawn. + /// Toggle bits via the Validation Overlay UI in LightmapTransferTool. + /// + public TransferValidator.TriIssue ValidationFilterMask = TransferValidator.TriIssue.None; + // Spot mode public bool SpotMode; public bool LockSelection; @@ -743,6 +750,7 @@ public void GlFillShellMatch(float ox, float oy, float sz, Vector2[] uv, int[] t public void GlFillValidation(float ox, float oy, float sz, Vector2[] uv, int[] t, int fN, int uN, TransferValidator.TriIssue[] perTri) { if (perTri == null) return; + var mask = ValidationFilterMask; int tot = 0, b = 0; GL.Begin(GL.TRIANGLES); for (int f = 0; f < fN && tot < MAX_TRI; f++) @@ -750,6 +758,7 @@ public void GlFillValidation(float ox, float oy, float sz, Vector2[] uv, int[] t int a0 = t[f*3], a1 = t[f*3+1], a2 = t[f*3+2]; if (!TOk(uv, uN, a0, a1, a2)) continue; var fl = (f < perTri.Length) ? perTri[f] : TransferValidator.TriIssue.None; + if (mask != TransferValidator.TriIssue.None && (fl & mask) == 0) continue; Color nc; if ((fl & TransferValidator.TriIssue.ZeroArea) != 0) nc = cValZero; else if ((fl & TransferValidator.TriIssue.Stretched) != 0) nc = cValStretch; @@ -768,12 +777,14 @@ public void GlFillValidation(float ox, float oy, float sz, Vector2[] uv, int[] t public void GlFillValidationOverlay(float ox, float oy, float sz, Vector2[] uv, int[] t, int fN, int uN, TransferValidator.TriIssue[] perTri) { if (perTri == null) return; + var mask = ValidationFilterMask; int tot = 0, b = 0; GL.Begin(GL.TRIANGLES); for (int f = 0; f < fN && tot < MAX_TRI; f++) { var fl = (f < perTri.Length) ? perTri[f] : TransferValidator.TriIssue.None; if (fl == TransferValidator.TriIssue.None) continue; + if (mask != TransferValidator.TriIssue.None && (fl & mask) == 0) continue; int a0 = t[f*3], a1 = t[f*3+1], a2 = t[f*3+2]; if (!TOk(uv, uN, a0, a1, a2)) continue; Color nc; diff --git a/Editor/Framework/UvToolContext.cs b/Editor/Framework/UvToolContext.cs index f06fceae..b8ee5685 100644 --- a/Editor/Framework/UvToolContext.cs +++ b/Editor/Framework/UvToolContext.cs @@ -9,6 +9,18 @@ namespace SashaRX.UnityMeshLab { + /// + /// Repack atlas resolution selection strategy. + /// uses directly; + /// derives the resolution from total 3D + /// surface area and . + /// + public enum ResolutionMode + { + Manual, + AutoFromTexelDensity, + } + /// /// Shared state container — one instance per window. /// Created in OnEnable, populated by . @@ -27,6 +39,156 @@ public class UvToolContext public int ShellPaddingPx; public int BorderPaddingPx; public bool RepackPerMesh; + + /// + /// xatlas maxChartSize in pixels. 0 = unbounded — a single huge chart + /// can force the atlas to grow past the requested resolution and + /// trigger downscale. Setting this to AtlasResolution (or smaller) + /// keeps every chart inside the atlas budget. + /// + public int XatlasMaxChartSize = 0; + + /// + /// xatlas bilinear — pads charts by 1 extra texel to keep bilinear + /// sampling from leaking neighbor charts at runtime. Default ON for + /// lightmap use (Unity samples lightmaps bilinearly). + /// + public bool XatlasBilinear = true; + + /// + /// xatlas blockAlign — snap chart placement to 4×4 texel blocks. + /// Required for compressed (BC1/DXT) lightmaps to avoid color bleed + /// across block boundaries. Costs some packing efficiency (typically + /// 3-8%). Default OFF since Unity progressive lightmaps usually run + /// uncompressed; flip to ON when shipping BC-compressed lightmaps. + /// + public bool XatlasBlockAlign = false; + + /// + /// Compression block size in texels. xatlas's blockAlign snaps to 4×4 + /// (BC1/BC3/BC5/BC7/ETC2/DXT*). Set to 5/6/8/10/12 for ASTC variants + /// — surfaces the intent but the actual post-pack snap to non-4 grids + /// is a follow-up; at 4 behaviour matches xatlas exactly. + /// + public int XatlasBlockSize = 4; + + /// + /// xatlas texelsPerUnit override in texels/UV-unit. 0 = let xatlas + /// auto-derive from atlas resolution. Manual values are useful when + /// a project enforces a fixed real-world texel density across all + /// lightmaps (e.g. 10 texels/m). + /// + public float XatlasTexelsPerUnit = 0f; + /// + /// xatlas brute-force packer — exhaustively searches placements for + /// each chart instead of using the fast heuristic. Default ON because + /// the heuristic leaves visible holes when chart sizes vary widely + /// (which happens whenever NormalizeTexelDensity is on). Cost is + /// ~1–3 seconds of extra wall-time per repack for typical models; + /// disable only if iteration speed beats atlas quality on a given + /// asset. + /// + public bool XatlasBruteForce = true; + /// xatlas rotate charts during pack (rotateCharts native option). Default true. + public bool XatlasRotateCharts = true; + /// xatlas align rotation to axis (rotateChartsToAxis native option). Default true. + public bool XatlasRotateChartsToAxis = true; + + /// + /// Pre-pack rescale of each shell's UV0 so UV-area is proportional + /// to 3D surface area. Produces uniform texels-per-world-unit in + /// the baked lightmap. Default ON for lightmap use; disable only + /// when preserving a baked-texture UV layout with intentional + /// non-uniform density. + /// + public bool NormalizeTexelDensity = true; + + /// + /// Auto-reparameterize shells whose UV0 stretch (Sander L² metric) exceeds + /// . Replaces the previous IsRibbon-based + /// trigger — now driven by an actual UV quality metric. ARAP local-global + /// solver redistributes vertices to minimize per-triangle isometric + /// distortion. Off by default (opt-in safety), but recommended ON for most + /// assets — stretched ribbons, twisted strips, and any high-distortion + /// shells get cleaned up at the parameterization level instead of via + /// downstream affine hacks. + /// + public bool ReparameterizeStretchedShells = true; // DEFAULT ON + + /// + /// Sander L² stretch above which a shell triggers ARAP re-parameterization. + /// 1.0 = isometric (perfect); 1.5 = mild stretch (typical artist unwrap); + /// 2.0 = noticeably stretched; 3.0+ = severely distorted. Default 1.5 fires + /// ARAP only on shells with measurable distortion. + /// + public float StretchThreshold = 1.5f; + + /// + /// ARAP iteration count for stretched-shell re-parameterization. Default 50, + /// matching 3ds Max's Relax-by-polygon-angles convergence behaviour. + /// + public int ArapIterations = 50; + + /// + /// Clamp final lightmap UV2 coordinates into [0,1] on both source + /// (post-xatlas) and target (post-transfer) meshes. Stray UVs outside + /// the unit square sample neighbouring atlas regions and bleed wrong + /// light onto the surface. xatlas + transfer normally stay in-range + /// but border padding, perturb fixups, and the topology enforcer can + /// push a few verts a fraction of a texel over. Cheap safety net, + /// default ON. + /// + public bool ClampLightmapToUnit = true; + + /// + /// Post-pack density correction. xatlas internally applies per-chart + /// scaling (sub-pixel anisotropic ceil + per-chart normalization for + /// extreme aspect ratios) that breaks uniform density. This pass + /// measures per-shell au2/a3 after xatlas and shrinks over-dense + /// shells uniformly around their UV2 centroid to match the median. + /// Shrink-only (never expands) so neighbours can't collide. Brings + /// density spread on the Carousel test from ~14× down to ~3×. + /// Default ON — needed to compensate xatlas's per-chart bias. + /// + public bool PostPackDensityCorrection = true; + + /// + /// Internal xatlas atlas oversampling factor (1, 2, 4, 8, 16). Default 4. + /// xatlas's Stage B does ceil(extents)/extents per axis per chart; + /// running the pack at N× the user resolution makes every shell + /// N× larger in pixel space, so most non-degenerate shells move + /// out of the sub-pixel regime where Stage B amplifies them. + /// Output UVs are normalised to [0,1] via /atlasW, so the + /// effective user atlas stays at . + /// Higher values reduce density spread further but raise the + /// brute-force pack cost (O(N × W × H)). 4× is a good default; + /// 8-16× for high-resolution lightmaps with many thin shells. + /// + public int InternalOversample = 4; + + /// + /// Fraction of [0,1]² atlas the normalized UVs should sum to. + /// Leaves slack for bin-packing inefficiency so the atlas doesn't + /// grow past the requested resolution. Default 0.75 (= 25% safety + /// margin). Lower → safer fit, smaller charts; higher → tighter + /// pack but risk of atlas overflow + downscale. + /// + public float TargetUvCoverage = 0.75f; + + /// + /// Strategy for choosing the repack atlas resolution. Manual uses the + /// explicit field; AutoFromTexelDensity + /// derives it from total 3D surface area and . + /// + public ResolutionMode RepackResolutionMode = ResolutionMode.Manual; + + /// + /// Target lightmap density in texels per meter. Used only when + /// is . + /// Default 10 texels/m matches a typical Unity progressive lightmap baseline. + /// + public float LightmapDensity = 10f; + public int IsolatedMeshGroup = -1; public UvToolContext() diff --git a/Editor/Framework/UvToolHub.cs b/Editor/Framework/UvToolHub.cs index c70eb9cb..8d1c038c 100644 --- a/Editor/Framework/UvToolHub.cs +++ b/Editor/Framework/UvToolHub.cs @@ -38,6 +38,11 @@ public T FindTool() where T : class, IUvTool float sideW = 300f; bool sideDragging; Vector2 sideScroll; + // Right sidebar — only rendered when ActiveTool implements + // IUvToolRightSidebar. Reapportioned by the right-edge resize handle. + float rightSideW = 360f; + bool rightSideDragging; + Vector2 rightSideScroll; int _cachedLodCount; int _cachedRendererCount; int _checkerUvChannel = 1; @@ -52,7 +57,11 @@ public T FindTool() where T : class, IUvTool string pendingToolId; string windowDebugTag; - [MenuItem("Tools/Mesh Lab")] + // The window opener and the validator submenu must share the same + // top-level "Tools/Mesh Lab" namespace, otherwise Unity collapses the + // action item into the submenu and the entry to open the window + // disappears (only "Validators ▸" remains visible). + [MenuItem("Tools/Mesh Lab/Open Mesh Lab", false, 0)] static void Open() { OpenWithTool(null); @@ -176,6 +185,8 @@ void RestoreWorkingMeshes() e.meshFilter.sharedMesh = e.fbxMesh; if (e.transferredMesh != null) { DestroyImmediate(e.transferredMesh); e.transferredMesh = null; } if (e.repackedMesh != null) { DestroyImmediate(e.repackedMesh); e.repackedMesh = null; } + e.repackedAtlasWidth = 0; + e.repackedAtlasHeight = 0; if (e.originalMesh != null && e.originalMesh != e.fbxMesh) { DestroyImmediate(e.originalMesh); e.originalMesh = null; } } } @@ -309,6 +320,33 @@ void OnGUI() DrawHubToolbar(); + // Pre-compute layout widths so the canvas (middle column) gets an + // explicit Width and shrinks first when the window is too narrow, + // while the sidebars hold their user-set widths down to their + // legibility minimums. Without an explicit canvas width IMGUI + // sized the middle column to its content, which pushed the + // right sidebar past the window's right edge. + const float LeftSidebarMinW = 220f; + const float RightSidebarMinW = 220f; + const float RightSidebarMaxW = 700f; + const float CanvasMinW = 120f; + const float HandleW = 4f; + + sideW = Mathf.Max(LeftSidebarMinW, sideW); + + var rightSidebar = ActiveTool as IUvToolRightSidebar; + float rightHandleW = rightSidebar != null ? HandleW : 0f; + if (rightSidebar != null) + { + float roomForRight = position.width - sideW - HandleW - rightHandleW - CanvasMinW; + float upper = Mathf.Clamp(roomForRight, RightSidebarMinW, RightSidebarMaxW); + rightSideW = Mathf.Clamp(rightSideW, RightSidebarMinW, upper); + } + + float canvasW = Mathf.Max(0f, + position.width - sideW - HandleW + - (rightSidebar != null ? (rightSideW + rightHandleW) : 0f)); + EditorGUILayout.BeginHorizontal(); // ── Left sidebar ���─ @@ -321,8 +359,9 @@ void OnGUI() DrawResizeHandle(); - // ── Right: canvas toolbar + canvas + status ── - EditorGUILayout.BeginVertical(); + // Middle column with explicit Width so it's the first thing to + // shrink when the window is too narrow. + EditorGUILayout.BeginVertical(GUILayout.Width(canvasW)); DrawCanvasToolbar(); bool showGroupPanel = ctx.RepackPerMesh && ctx.MeshGroupCount(ctx.PreviewLod) > 1; @@ -343,9 +382,51 @@ void OnGUI() EditorGUILayout.EndVertical(); + // Right sidebar (opt-in via IUvToolRightSidebar). The clamp + width + // computation already happened at the top of OnGUI so we just + // render at the resolved rightSideW here. + if (rightSidebar != null) + { + DrawRightResizeHandle(); + EditorGUILayout.BeginVertical(GUILayout.Width(rightSideW)); + rightSideScroll = EditorGUILayout.BeginScrollView(rightSideScroll); + rightSidebar.OnDrawRightSidebar(); + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndHorizontal(); } + void DrawRightResizeHandle() + { + var r = GUILayoutUtility.GetRect(4, 4, GUILayout.ExpandHeight(true)); + EditorGUI.DrawRect(r, new Color(.13f, .13f, .13f)); + EditorGUIUtility.AddCursorRect(r, MouseCursor.ResizeHorizontal); + int id = GUIUtility.GetControlID(FocusType.Passive); + if (Event.current.type == EventType.MouseDown && r.Contains(Event.current.mousePosition)) + { GUIUtility.hotControl = id; rightSideDragging = true; Event.current.Use(); } + if (rightSideDragging && Event.current.type == EventType.MouseDrag) + { + // Mirror the per-frame clamp so dragging can never push the + // sidebar off-screen or shrink the canvas past its minimum. + // The constants here intentionally match the pre-compute + // block at the top of OnGUI. + const float RightSidebarMinW = 220f; + const float RightSidebarMaxW = 700f; + const float CanvasMinW = 120f; + const float HandleW = 4f; + float roomForRight = position.width - sideW - HandleW - HandleW - CanvasMinW; + float upper = Mathf.Clamp(roomForRight, RightSidebarMinW, RightSidebarMaxW); + rightSideW = Mathf.Clamp(position.width - Event.current.mousePosition.x, + RightSidebarMinW, upper); + Event.current.Use(); + Repaint(); + } + if (Event.current.rawType == EventType.MouseUp && rightSideDragging) + { rightSideDragging = false; Event.current.Use(); } + } + void OnSceneGUI(SceneView sv) { ActiveTool?.OnSceneGUI(sv); @@ -742,7 +823,16 @@ void DrawMeshGroupPanel() for (int i = 0; i < groupKeys.Count; i++) { bool active = ctx.IsolatedMeshGroup == i; - if (active) GUI.backgroundColor = new Color(.35f, .85f, .4f); + // Tint each group button with the same per-group palette + // entry the Prefab Builder hierarchy uses, so the user can + // visually correlate the chain they see in the hierarchy + // with the group they're selecting here. Active selection + // overrides with the green highlight; non-selected buttons + // get the group's hue at full strength. + Color groupColor = MeshGroupColors.GetColor(groupKeys[i]); + GUI.backgroundColor = active + ? new Color(.35f, .85f, .4f) + : groupColor; if (GUILayout.Button(groupKeys[i], EditorStyles.miniButton)) { if (active) @@ -756,7 +846,7 @@ void DrawMeshGroupPanel() ctx.IsolatedMeshGroupKey = groupKeys[i]; } } - if (active) GUI.backgroundColor = bg; + GUI.backgroundColor = bg; } EditorGUILayout.EndScrollView(); @@ -1000,7 +1090,7 @@ void DrawResizeHandle() if (Event.current.type == EventType.MouseDown && r.Contains(Event.current.mousePosition)) { GUIUtility.hotControl = id; sideDragging = true; Event.current.Use(); } if (sideDragging && Event.current.type == EventType.MouseDrag) - { sideW = Mathf.Clamp(Event.current.mousePosition.x, 200, 520); Event.current.Use(); Repaint(); } + { sideW = Mathf.Clamp(Event.current.mousePosition.x, 200, 900); Event.current.Use(); Repaint(); } if (Event.current.rawType == EventType.MouseUp && sideDragging) { sideDragging = false; Event.current.Use(); } } diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 18e1858b..ac5cef0a 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -100,6 +100,13 @@ public class TransferResult public int shellsRejected; // shells where UV2 was not written (too many issues) public int shellsOverlapFixed; // force3D shells relocated due to UV2 overlap + // ─── Topology enforcement snapshot (per-target, captured by Transfer) ─── + // Copied from LastTopology* immediately after EnforceShellTopologyOnUv2 + // so multi-mesh runs don't all read the final target's global values. + public int topologyIterations; + public int topologyFixed; + public bool topologyCapHit; + // ─── Cross-LOD overlap hints ─── // Populated for merged shells to propagate source selection to subsequent LODs. public List overlapHints; @@ -753,11 +760,29 @@ static void RescoreMergedShells( // fewer inverted/zero-area triangles. // ═══════════════════════════════════════════════════════════ + static float ComputeUv2PixelMargin(int atlasWidth, int atlasHeight, float pixels, float fallback) + { + int w = Mathf.Max(0, atlasWidth); + int h = Mathf.Max(0, atlasHeight); + int dim = (w > 0 && h > 0) ? Mathf.Min(w, h) : Mathf.Max(w, h); + return dim > 0 ? pixels / dim : fallback; + } + public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, List previousLodHints = null, - List previousLodMatchHints = null) + List previousLodMatchHints = null, + int sourceAtlasWidth = 0, + int sourceAtlasHeight = 0) { var result = new TransferResult(); + float uv2OobMargin = ComputeUv2PixelMargin(sourceAtlasWidth, sourceAtlasHeight, 1.25f, 0.005f); + float uv2BoundsTolerance = ComputeUv2PixelMargin(sourceAtlasWidth, sourceAtlasHeight, 2.5f, 0.01f); + if (sourceAtlasWidth > 0 || sourceAtlasHeight > 0) + { + UvtLog.Verbose(UvtLog.Category.Match, + $"[GroupedTransfer] UV2 tolerances from atlas {sourceAtlasWidth}x{sourceAtlasHeight}: " + + $"oobMargin={uv2OobMargin:F6}, boundsTol={uv2BoundsTolerance:F6}"); + } // Source data var srcVerts = sourceMesh.vertices; @@ -2021,7 +2046,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, srcTransforms[si], srcIsRibbon[si], srcRibbonAxis[si], srcRibbonAxis2[si], srcRibbonCentroid[si], srcUv2Min, srcUv2Max, groupMembers, - kRayMaxDist); + kRayMaxDist, uv2BoundsTolerance); var best = SelectBestCandidate(allCandidates, tShell.faceIndices, tgtTris, tUv0); if (best.HasValue) @@ -2746,7 +2771,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, srcIsRibbon[chosenSrc], srcRibbonAxis[chosenSrc], srcRibbonAxis2[chosenSrc], srcRibbonCentroid[chosenSrc], srcUv2Min, srcUv2Max, null, - kRayMaxDist); + kRayMaxDist, uv2BoundsTolerance); var bestOverlap = SelectBestCandidate( overlapCandidates, tShell.faceIndices, tgtTris, tUv0); @@ -2841,7 +2866,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, // the outliers using the matched source's constrained UV0 lookup. if (bestMergedUv2 != null && bestMergedUv2.Count > 1 && chosenSrc >= 0) { - const float kUv2Margin = 0.005f; + float kUv2Margin = uv2OobMargin; Vector2 sMin = srcUv2Min[chosenSrc]; Vector2 sMax = srcUv2Max[chosenSrc]; @@ -3077,7 +3102,7 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, // Penalize xform if it extrapolates beyond source shell's UV2 bounds. // Extrapolation is the primary cause of cross-source UV2 overlaps, // since interp stays within source UV2 convex hull by construction. - const float kOobMargin = 0.005f; + float kOobMargin = uv2OobMargin; Vector2 srcBMin2 = srcUv2Min[chosenSrc]; Vector2 srcBMax2 = srcUv2Max[chosenSrc]; Vector2 xfBMin = new Vector2(float.MaxValue, float.MaxValue); @@ -3288,12 +3313,19 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, UvtLog.Info($"[GroupedTransfer] Post-fix total: {totalOutlierVerts} outlier verts corrected"); } - // ── Post-transfer UV2 overlap detection & relocation ── - // Force3D shells may land in UV2 regions occupied by other shells. - // Detect overlapping force3D shells and collapse them to their source's - // UV2 centroid to eliminate overlaps. + // ── Post-transfer UV2 overlap detection & reporting ── + // Force3D fallback shells may land in UV2 regions occupied by other + // shells. Previously this stage collapsed every offender to its + // source's UV2 centroid — a "fix" that destroyed the shell: 40 + // faces sharing one UV2 point bake into a single lightmap pixel, + // far worse than the bleeding the collapse was meant to prevent. + // + // We now only DETECT and report the overlap, leaving the shell's + // 3D-projected UV2 layout intact. The bake gets some bleeding for + // the offending shells but each face still owns a distinct texel. + // A proper free-space relocator (preserving shell shape) is the + // right long-term fix — until then, bleeding beats collapse. { - // Build per-shell UV2 AABB var shellUv2Min = new Vector2[tgtShells.Count]; var shellUv2Max = new Vector2[tgtShells.Count]; var shellHasUv2 = new bool[tgtShells.Count]; @@ -3317,51 +3349,41 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, shellHasUv2[tsi] = hasAny; } - int overlapsFixed = 0; + int overlapsReported = 0; for (int tsi = 0; tsi < tgtShells.Count; tsi++) { if (!tgtForce3DFallback[tsi]) continue; if (!shellHasUv2[tsi]) continue; - bool overlaps = false; + int firstOverlapWith = -1; for (int tsj = 0; tsj < tgtShells.Count; tsj++) { if (tsj == tsi) continue; if (!shellHasUv2[tsj]) continue; - if (tgtForce3DFallback[tsj]) continue; // don't compare force3D vs force3D + if (tgtForce3DFallback[tsj]) continue; if (shellUv2Min[tsi].x < shellUv2Max[tsj].x && shellUv2Max[tsi].x > shellUv2Min[tsj].x && shellUv2Min[tsi].y < shellUv2Max[tsj].y && shellUv2Max[tsi].y > shellUv2Min[tsj].y) { - overlaps = true; + firstOverlapWith = tsj; break; } } - if (overlaps) + if (firstOverlapWith >= 0) { - int src = result.targetShellToSourceShell[tsi]; - Vector2 centroid; - if (src >= 0 && src < srcUv2Min.Length) - centroid = (srcUv2Min[src] + srcUv2Max[src]) * 0.5f; - else - centroid = (shellUv2Min[tsi] + shellUv2Max[tsi]) * 0.5f; - - foreach (int vi in tgtShells[tsi].vertexIndices) - { - if (vi < result.uv2.Length) - result.uv2[vi] = centroid; - } - overlapsFixed++; + overlapsReported++; result.shellsOverlapFixed++; - UvtLog.Info($"[GroupedTransfer] Overlap fix: t{tsi} collapsed to " + - $"src{src} centroid ({centroid.x:F4},{centroid.y:F4})"); + UvtLog.Warn($"[GroupedTransfer] UV2 overlap: t{tsi} (force3D fallback) " + + $"overlaps t{firstOverlapWith} — leaving UV2 untouched " + + $"(bleeding > collapse; consider re-unwrapping in DCC)"); } } - if (overlapsFixed > 0) - UvtLog.Info($"[GroupedTransfer] Overlap fix: {overlapsFixed} force3D shells relocated"); + if (overlapsReported > 0) + UvtLog.Warn($"[GroupedTransfer] UV2 overlap: {overlapsReported} force3D shells " + + $"have overlapping UV2 with non-fallback shells (lightmap bleeding likely)"); } // ── Classify all shells ── @@ -3468,6 +3490,20 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, // ── Shell topology consistency: detect & fix displaced vertices ── EnforceShellTopologyOnUv2(result.uv2, tVerts, tgtTris, tgtShells); + // Snapshot the per-call topology counters into the result so downstream + // consumers (BenchmarkRecorder) get accurate per-target values; the + // static LastTopology* fields get overwritten on the next Transfer call. + result.topologyIterations = LastTopologyIterations; + result.topologyFixed = LastTopologyFixed; + result.topologyCapHit = LastTopologyCapHit; + + // ── Collapse-to-line diagnostic ── + // Detect target shells whose UV2 layout has collapsed to a line + // (one bbox dim near zero) or extreme sliver (UV aspect ≫ 3D + // aspect) — pure logging, no behaviour change. Especially useful + // on lower LODs where degenerate parameterisation can ride + // through similarity-transform / strip-param transfer. + DiagnoseCollapsedTargetShells(tgtShells, tgtTris, tVerts, result.uv2); // UV2 bounds check int oob = 0; @@ -3476,7 +3512,8 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, { var uv = result.uv2[i]; uvMin = Vector2.Min(uvMin, uv); uvMax = Vector2.Max(uvMax, uv); - if (uv.x < -0.01f || uv.x > 1.01f || uv.y < -0.01f || uv.y > 1.01f) oob++; + if (uv.x < -uv2BoundsTolerance || uv.x > 1f + uv2BoundsTolerance || + uv.y < -uv2BoundsTolerance || uv.y > 1f + uv2BoundsTolerance) oob++; } if (oob > 0) UvtLog.Warn($"[GroupedTransfer] '{targetMesh.name}': {oob} verts outside 0-1! " + @@ -3557,9 +3594,23 @@ public static TransferResult Transfer(Mesh targetMesh, Mesh sourceMesh, // Works on raw UV2 array + UvShell list (independent of TargetTransferState) // ═══════════════════════════════════════════════════════════ + // ── Benchmark counters (reset by the caller, read after EnforceShellTopologyOnUv2) ── + /// + /// Number of Laplacian iterations actually executed in the most recent + /// call. Capped at kMaxTopologyIterations. + /// + public static int LastTopologyIterations; + /// Total vertices moved by the most recent topology enforcement pass. + public static int LastTopologyFixed; + /// True when the iteration cap was reached and more fixes were still pending. + public static bool LastTopologyCapHit; + static void EnforceShellTopologyOnUv2( Vector2[] uv2, Vector3[] verts, int[] triangles, List shells) { + LastTopologyIterations = 0; + LastTopologyFixed = 0; + LastTopologyCapHit = false; if (uv2 == null || uv2.Length == 0) return; int faceCount = triangles.Length / 3; @@ -3793,17 +3844,22 @@ static void EnforceShellTopologyOnUv2( } totalFixed += fixedThisPass; - UvtLog.Verbose($"[ShellTopology] iter={iteration} fixed={fixedThisPass} candidates={candidates.Count}"); + LastTopologyIterations = iteration + 1; + UvtLog.Verbose(UvtLog.Category.Topology, $"iter={iteration} fixed={fixedThisPass} candidates={candidates.Count}"); if (fixedThisPass == 0) break; if (iteration == kMaxTopologyIterations - 1 && fixedThisPass > 0) - UvtLog.Warn($"[ShellTopology] Cap reached ({kMaxTopologyIterations} iterations) " + + { + LastTopologyCapHit = true; + UvtLog.Warn(UvtLog.Category.Topology, $"Cap reached ({kMaxTopologyIterations} iterations) " + $"with {fixedThisPass} vertices still fixable — consider increasing cap"); + } } + LastTopologyFixed = totalFixed; if (totalFixed > 0) { - UvtLog.Info($"[GroupedTransfer] Shell topology enforcement fixed {totalFixed} displaced vertices"); + UvtLog.Info(UvtLog.Category.Topology, $"Shell topology enforcement fixed {totalFixed} displaced vertices"); } } @@ -4062,7 +4118,7 @@ static List GenerateOverlapCandidates( // Cross-source UV2 guard data Vector2[] srcUv2Min, Vector2[] srcUv2Max, List overlapGroupMembers, // Thresholds - float kRayMaxDist) + float kRayMaxDist, float uv2BoundsTolerance) { var candidates = new List(); int[] tgtTris = null; // not needed — issues counted by caller @@ -4243,7 +4299,8 @@ static List GenerateOverlapCandidates( foreach (var kv in uv2Map) { Vector2 uv = kv.Value; - if (uv.x < -0.01f || uv.x > 1.01f || uv.y < -0.01f || uv.y > 1.01f) + if (uv.x < -uv2BoundsTolerance || uv.x > 1f + uv2BoundsTolerance || + uv.y < -uv2BoundsTolerance || uv.y > 1f + uv2BoundsTolerance) { partXfRejected = true; break; @@ -4331,8 +4388,8 @@ static List GenerateOverlapCandidates( } // Reject if result goes outside 0-1 range (catches wild extrapolation) - if (xfMin.x < -0.01f || xfMax.x > 1.01f || - xfMin.y < -0.01f || xfMax.y > 1.01f) + if (xfMin.x < -uv2BoundsTolerance || xfMax.x > 1f + uv2BoundsTolerance || + xfMin.y < -uv2BoundsTolerance || xfMax.y > 1f + uv2BoundsTolerance) rejected = true; // Reject if result extends too far beyond source's UV2 AABB @@ -4721,5 +4778,123 @@ static List MergeFragmentShells( return newShells; } + + /// + /// Post-transfer diagnostic: scan target shells for UV2 layouts that + /// have collapsed to a line / extreme sliver. Detection is geometric + /// only (UV2 bbox + UV2 aspect vs 3D in-plane aspect), independent of + /// the source/transfer path that produced the UV2. Pure logging — no + /// modifications to uv2. + /// + /// Flags: + /// - bbox.x < epsilon OR bbox.y < epsilon → "line" + /// - uv2_aspect / 3d_aspect ≥ 5 → "sliver" (UV stretched far beyond + /// what the surface shape would warrant) + /// - uv2 triangle-area / uv2 bbox-area < 0.05 → "degenerate" + /// (verts collinear within the bbox) + /// + static void DiagnoseCollapsedTargetShells( + List tgtShells, int[] tgtTris, Vector3[] tVerts, Vector2[] uv2) + { + if (tgtShells == null || tgtTris == null || tVerts == null || uv2 == null) return; + const float LINE_EPS = 1e-5f; + const float ASPECT_RATIO_THRESHOLD = 5f; + const float FILL_RATIO_THRESHOLD = 0.05f; + int reported = 0; + const int MAX_REPORTS = 15; + + var sb = new System.Text.StringBuilder(128); + int totalCollapsed = 0; + for (int si = 0; si < tgtShells.Count; si++) + { + var shell = tgtShells[si]; + if (shell?.vertexIndices == null || shell.vertexIndices.Count == 0) continue; + if (shell.faceIndices == null || shell.faceIndices.Count == 0) continue; + + // UV2 bbox + triangle area + Vector2 mn = new Vector2(float.MaxValue, float.MaxValue); + Vector2 mx = new Vector2(float.MinValue, float.MinValue); + foreach (int v in shell.vertexIndices) + { + if ((uint)v >= (uint)uv2.Length) continue; + mn = Vector2.Min(mn, uv2[v]); + mx = Vector2.Max(mx, uv2[v]); + } + Vector2 sz = mx - mn; + if (sz.x < 0f || sz.y < 0f) continue; + + double triAreaUv2 = 0.0; + double triArea3D = 0.0; + foreach (int f in shell.faceIndices) + { + int t = f * 3; + if ((uint)(t + 2) >= (uint)tgtTris.Length) continue; + int i0 = tgtTris[t], i1 = tgtTris[t + 1], i2 = tgtTris[t + 2]; + if ((uint)i0 >= (uint)uv2.Length || (uint)i1 >= (uint)uv2.Length || (uint)i2 >= (uint)uv2.Length) continue; + Vector2 a = uv2[i0], b = uv2[i1], c = uv2[i2]; + triAreaUv2 += System.Math.Abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)) * 0.5; + if ((uint)i0 < (uint)tVerts.Length && (uint)i1 < (uint)tVerts.Length && (uint)i2 < (uint)tVerts.Length) + { + Vector3 p0 = tVerts[i0], p1 = tVerts[i1], p2 = tVerts[i2]; + triArea3D += Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5; + } + } + + string reason = null; + + bool collapsedToLine = sz.x < LINE_EPS || sz.y < LINE_EPS; + if (collapsedToLine) + { + reason = "line (bbox dim ≈ 0)"; + } + else + { + float uv2Aspect = Mathf.Max(sz.x, sz.y) / Mathf.Max(LINE_EPS, Mathf.Min(sz.x, sz.y)); + + // Cheap 3D aspect estimate: AABB of shell verts, drop smallest + // dim, ratio of the other two. Doesn't need PCA for a sanity + // check. + Vector3 mn3 = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 mx3 = new Vector3(float.MinValue, float.MinValue, float.MinValue); + foreach (int v in shell.vertexIndices) + { + if ((uint)v >= (uint)tVerts.Length) continue; + mn3 = Vector3.Min(mn3, tVerts[v]); + mx3 = Vector3.Max(mx3, tVerts[v]); + } + Vector3 sz3 = mx3 - mn3; + float dx = Mathf.Abs(sz3.x), dy = Mathf.Abs(sz3.y), dz = Mathf.Abs(sz3.z); + float d0 = Mathf.Min(dx, Mathf.Min(dy, dz)); + float d2 = Mathf.Max(dx, Mathf.Max(dy, dz)); + float d1 = dx + dy + dz - d0 - d2; + float aspect3D = d1 > 1e-8f ? d2 / d1 : 1f; + + if (uv2Aspect / Mathf.Max(1f, aspect3D) >= ASPECT_RATIO_THRESHOLD) + reason = $"sliver (uv2 {uv2Aspect:F1}:1 vs 3D {aspect3D:F1}:1)"; + else + { + float bboxAreaUv2 = sz.x * sz.y; + if (bboxAreaUv2 > 1e-12f) + { + float fill = (float)(triAreaUv2 / bboxAreaUv2); + if (fill < FILL_RATIO_THRESHOLD && triAreaUv2 > 0) + reason = $"degenerate (fill {fill * 100f:F1}%)"; + } + } + } + + if (reason == null) continue; + totalCollapsed++; + if (reported < MAX_REPORTS) + { + UvtLog.Warn(UvtLog.Category.Validation, + $"[CollapseDiag] target shell #{si} ({shell.faceIndices.Count}f, {shell.vertexIndices.Count}v) UV2 bbox=({sz.x:F5}, {sz.y:F5}) area={triAreaUv2:F6} — {reason}"); + reported++; + } + } + if (totalCollapsed > 0) + UvtLog.Warn(UvtLog.Category.Validation, + $"[CollapseDiag] {totalCollapsed} target shell(s) flagged as collapsed/sliver/degenerate (first {Mathf.Min(totalCollapsed, MAX_REPORTS)} logged above)"); + } } } diff --git a/Editor/HardEdgeShellAnalyzer.cs b/Editor/HardEdgeShellAnalyzer.cs new file mode 100644 index 00000000..58bc3ae0 --- /dev/null +++ b/Editor/HardEdgeShellAnalyzer.cs @@ -0,0 +1,216 @@ +// HardEdgeShellAnalyzer.cs — detect shells whose 3D surface contains hard +// edges (face-normal-pair angle above a threshold) such that cutting along +// them would split the shell into multiple connected sub-components. +// +// Pure analysis. mesh.uv and the shell data structures are not mutated; the +// caller decides what to do with the result. Today this powers a logging +// diagnostic; a follow-up will use the per-face sub-component assignment to +// drive an actual pre-pack split by emitting distinct faceShellIds for each +// sub-component into xatlas's AddUvMesh call. + +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class HardEdgeShellAnalyzer + { + public readonly struct ShellSplitInfo + { + public readonly int shellId; + /// Raw Union-Find component count after cutting hard edges. + public readonly int totalComponents; + /// Components with at least minSubshellFaces faces — these are + /// the ones worth materialising as separate xatlas charts. + public readonly int eligibleComponents; + /// Number of edge pairs (face/face adjacencies) whose normals + /// disagreed by more than the angle threshold. + public readonly int hardEdgeCount; + /// Per-face sub-component index, indexed by position inside + /// the shell's faceIndices list (NOT by global face index). Drives + /// the future actual split. + public readonly int[] perFaceComponent; + + public ShellSplitInfo(int id, int total, int eligible, int hard, int[] perFace) + { + shellId = id; + totalComponents = total; + eligibleComponents = eligible; + hardEdgeCount = hard; + perFaceComponent = perFace; + } + } + + public readonly struct AnalysisResult + { + public readonly int totalShellsAnalyzed; + public readonly int shellsWithHardEdges; + /// Shells whose eligibleComponents ≥ 2 — the only ones a + /// real split would actually act on. User's rule: don't cut if the + /// outcome is "unstitch one face from the rest"; cut only when + /// the shell genuinely separates into multiple chunks. + public readonly int shellsSplittable; + public readonly List splittable; + + public AnalysisResult(int total, int withHard, int splittableCount, List list) + { + totalShellsAnalyzed = total; + shellsWithHardEdges = withHard; + shellsSplittable = splittableCount; + splittable = list; + } + } + + /// + /// For each shell, build face-adjacency by shared edge (2 verts), tag + /// each adjacency as "hard" when the angle between geometric face + /// normals exceeds , then run + /// Union-Find on the soft-only adjacency. A shell is reported as + /// splittable only when at least 2 of the resulting sub-components + /// have ≥ faces each — single-face + /// splinters don't count. + /// + public static AnalysisResult Analyze( + List shells, int[] tris, Vector3[] positions, + float angleThresholdDeg = 45f, int minSubshellFaces = 2) + { + if (shells == null || tris == null || positions == null) + return new AnalysisResult(0, 0, 0, new List()); + + int triCount = tris.Length / 3; + int posLen = positions.Length; + float cosThreshold = Mathf.Cos(angleThresholdDeg * Mathf.Deg2Rad); + + // Precompute face normals once across the whole mesh. + var faceNormals = new Vector3[triCount]; + for (int f = 0; f < triCount; f++) + { + int t = f * 3; + if ((uint)(t + 2) >= (uint)tris.Length) continue; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)posLen || (uint)i1 >= (uint)posLen || (uint)i2 >= (uint)posLen) continue; + Vector3 cross = Vector3.Cross(positions[i1] - positions[i0], positions[i2] - positions[i0]); + faceNormals[f] = cross.sqrMagnitude > 1e-12f ? cross.normalized : Vector3.up; + } + + int totalAnalyzed = 0; + int withHardEdges = 0; + int splittableCount = 0; + var splittable = new List(); + var edgeFaces = new Dictionary>(64); + var faceIdx = new Dictionary(64); + + foreach (var shell in shells) + { + if (shell?.faceIndices == null || shell.faceIndices.Count < 2) continue; + totalAnalyzed++; + + // Build edge → faces map for this shell only. + edgeFaces.Clear(); + foreach (int f in shell.faceIndices) + { + int t = f * 3; + if ((uint)(t + 2) >= (uint)tris.Length) continue; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + AddEdge(edgeFaces, i0, i1, f); + AddEdge(edgeFaces, i1, i2, f); + AddEdge(edgeFaces, i2, i0, f); + } + + int nf = shell.faceIndices.Count; + faceIdx.Clear(); + for (int i = 0; i < nf; i++) faceIdx[shell.faceIndices[i]] = i; + + var parent = new int[nf]; + var rankArr = new int[nf]; + for (int i = 0; i < nf; i++) parent[i] = i; + + int hardCount = 0; + foreach (var kv in edgeFaces) + { + var fs = kv.Value; + if (fs.Count < 2) continue; + int f0 = fs[0]; + for (int j = 1; j < fs.Count; j++) + { + int fj = fs[j]; + float dot = Vector3.Dot(faceNormals[f0], faceNormals[fj]); + if (dot < cosThreshold) + { + hardCount++; + continue; + } + if (faceIdx.TryGetValue(f0, out int a) && faceIdx.TryGetValue(fj, out int b)) + Union(parent, rankArr, a, b); + } + } + if (hardCount > 0) withHardEdges++; + + // Component sizes (key = root index in `parent`). + var compSize = new Dictionary(); + for (int i = 0; i < nf; i++) + { + int r = Find(parent, i); + if (!compSize.TryGetValue(r, out int c)) c = 0; + compSize[r] = c + 1; + } + + int eligible = 0; + foreach (var c in compSize.Values) + if (c >= minSubshellFaces) eligible++; + + if (eligible >= 2) + { + // Materialise a stable component index per face (0..K-1) + // so the future actual split can read this directly. + var rootToIdx = new Dictionary(); + int next = 0; + var perFace = new int[nf]; + for (int i = 0; i < nf; i++) + { + int r = Find(parent, i); + if (!rootToIdx.TryGetValue(r, out int idx)) { idx = next++; rootToIdx[r] = idx; } + perFace[i] = idx; + } + splittableCount++; + splittable.Add(new ShellSplitInfo(shell.shellId, compSize.Count, eligible, hardCount, perFace)); + } + } + + return new AnalysisResult(totalAnalyzed, withHardEdges, splittableCount, splittable); + } + + static void AddEdge(Dictionary> map, int a, int b, int face) + { + // Canonical key: pack the smaller vert index in the high bits so + // (a,b) and (b,a) hash the same. (long)>>32 keeps int->long sign + // bits clean. + long key = a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a; + if (!map.TryGetValue(key, out var list)) + { + list = new List(2); + map[key] = list; + } + list.Add(face); + } + + static int Find(int[] p, int i) + { + while (p[i] != i) + { + p[i] = p[p[i]]; + i = p[i]; + } + return i; + } + + static void Union(int[] p, int[] r, int a, int b) + { + int ra = Find(p, a), rb = Find(p, b); + if (ra == rb) return; + if (r[ra] < r[rb]) p[ra] = rb; + else if (r[ra] > r[rb]) p[rb] = ra; + else { p[rb] = ra; r[ra]++; } + } + } +} diff --git a/Editor/HardEdgeShellAnalyzer.cs.meta b/Editor/HardEdgeShellAnalyzer.cs.meta new file mode 100644 index 00000000..6169b218 --- /dev/null +++ b/Editor/HardEdgeShellAnalyzer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b5eea6d1761463283ab254d39476cb8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MeshAreaHelper.cs b/Editor/MeshAreaHelper.cs new file mode 100644 index 00000000..d225cf84 --- /dev/null +++ b/Editor/MeshAreaHelper.cs @@ -0,0 +1,93 @@ +// MeshAreaHelper.cs — shared 3D surface-area math used by the Repack UI +// info labels and the Auto-from-texel-density resolution calculator. +// +// All inputs are assumed to be in mesh-local space and unit-meters (the +// Unity convention). Transform scale is intentionally not applied — that +// is an artist-side concern (the same FBX should bake the same lightmap +// regardless of where it is placed in the scene). + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class MeshAreaHelper + { + /// + /// Sum of triangle areas across all meshes, in mesh-local units + /// (assumed meters). Null meshes and meshes with zero vertices + /// are skipped silently. Sub-mesh triangle topology is honoured. + /// + internal static double ComputeTotal3DAreaMeters(IEnumerable meshes) + { + if (meshes == null) return 0.0; + double total = 0.0; + foreach (var m in meshes) + { + if (m == null) continue; + var verts = m.vertices; + if (verts == null || verts.Length == 0) continue; + int subMeshCount = m.subMeshCount; + for (int s = 0; s < subMeshCount; s++) + { + var tris = m.GetTriangles(s); + if (tris == null) continue; + for (int i = 0; i + 2 < tris.Length; i += 3) + { + int i0 = tris[i]; + int i1 = tris[i + 1]; + int i2 = tris[i + 2]; + if ((uint)i0 >= (uint)verts.Length || + (uint)i1 >= (uint)verts.Length || + (uint)i2 >= (uint)verts.Length) continue; + var p0 = verts[i0]; + var p1 = verts[i1]; + var p2 = verts[i2]; + total += Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5; + } + } + } + return total; + } + + /// + /// Compute the smallest power-of-two atlas resolution (clamped to + /// [64, 4096]) that satisfies the requested texel density given + /// total 3D area and the active UV coverage budget. + /// + /// needed = sqrt(area * density² / coverage) + /// = sqrt(area / coverage) * density + /// + /// Returned value is suitable for direct assignment to + /// RepackOptions.resolution. + /// + internal static uint ComputeAutoResolution(double total3DArea, float texelsPerMeter, float coverage) + { + if (texelsPerMeter <= 0f) texelsPerMeter = 1f; + if (coverage <= 0f) coverage = 1f; + if (total3DArea <= 0.0) return 64u; + + double density = texelsPerMeter; + double needed = Math.Sqrt(total3DArea * density * density / coverage); + int ceil = (int)Math.Ceiling(needed); + uint pow2 = NextPow2((uint)Mathf.Max(1, ceil)); + if (pow2 < 64u) pow2 = 64u; + if (pow2 > 4096u) pow2 = 4096u; + return pow2; + } + + /// Smallest power of two ≥ v (for v ≥ 1). + internal static uint NextPow2(uint v) + { + if (v <= 1u) return 1u; + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + return v + 1u; + } + } +} diff --git a/Editor/MeshAreaHelper.cs.meta b/Editor/MeshAreaHelper.cs.meta new file mode 100644 index 00000000..1e3944a9 --- /dev/null +++ b/Editor/MeshAreaHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95ac60a4a9334a218c92982c612ab6b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Settings/TestSuiteAsset.cs b/Editor/Settings/TestSuiteAsset.cs new file mode 100644 index 00000000..da6d0c04 --- /dev/null +++ b/Editor/Settings/TestSuiteAsset.cs @@ -0,0 +1,125 @@ +// TestSuiteAsset.cs — ScriptableObject registry of benchmark test cases for the +// UV2 lightmap transfer pipeline. Used to drive repeatable manual runs across +// a fixed set of LODGroups + expected metric ranges. See TRANSFER_BENCHMARK.md. + +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace SashaRX.UnityMeshLab +{ + [CreateAssetMenu(menuName = "Lightmap UV Tool/Test Suite", fileName = "UvTransferTestSuite")] + public class TestSuiteAsset : ScriptableObject + { + [System.Serializable] + public class ExpectedRange + { + [Tooltip("BenchmarkRecorder metric name (e.g. invertedCount, overlapShellPairs, " + + "shellsRejected, symSplitFallbackCount, topologyCapHit).")] + public string metric = ""; + public float min = 0f; + public float max = 0f; + } + + [System.Serializable] + public class TestCase + { + [Tooltip("Short human label for this case; appears in CSV/JSON runLabel column.")] + public string label = ""; + + [Tooltip("Source FBX asset. The custom inspector's Open button will look for the " + + "first LODGroup or MeshRenderer under this asset and wire it into the tool.")] + public Object fbxAsset; + + [Tooltip("Optional hierarchy path inside the FBX prefab (e.g. 'Root/LOD_root') — " + + "if empty, the first LODGroup found is used.")] + public string lodGroupPath = ""; + + [Tooltip("Go/Stop metric expectations for this case. Informational — used in " + + "TRANSFER_BENCHMARK.md reviews, not enforced automatically.")] + public List expectations = new List(); + + [TextArea(2, 6)] + [Tooltip("Free-form notes about this test case (known regressions, history, etc).")] + public string notes = ""; + } + + [Tooltip("Ordered list of benchmark cases. Labels are used as the runLabel column in " + + "BenchmarkRecorder output so different models can be compared in one CSV.")] + public List cases = new List(); + + [Tooltip("Free-form description of this suite — what it's for, when it was updated.")] + [TextArea(2, 6)] + public string description = ""; + + /// + /// Parameter sweep configuration — drives ExecSweep to run the Full Pipeline across + /// the cartesian product of the listed atlas resolutions / paddings. One CSV + JSON + /// per cell, each with a runLabel of sweep_res{R}_pad{S}_bdr{B}. + /// + [System.Serializable] + public class SweepMatrix + { + [Tooltip("Atlas resolutions (pixels) to sweep. Each resolution is combined with every " + + "shell padding and border padding value below.")] + public int[] atlasResolutions = { 256, 512, 2048 }; + + [Tooltip("Shell padding values (pixels) to sweep.")] + public int[] shellPaddingPxVariants = { 2, 4, 8, 32 }; + + [Tooltip("Border padding values (pixels) to sweep. Leave as {0} if you don't want " + + "to vary it — a single-value array still produces N×M×1 combinations.")] + public int[] borderPaddingPxVariants = { 0 }; + + [Tooltip("ARAP stretched-shell reparameterization variants. 0 = OFF; >0 = ON with that many " + + "local-global iterations. Default {0} (ARAP off — opt in by adding e.g. 50 or 100 to " + + "this list). Each non-zero value spawns a separate sweep cell.")] + public int[] arapIterationsVariants = { 0, 50 }; + + [Tooltip("Sander L² stretch threshold variants used to gate ARAP. 1.0 = isometric; 1.5 = typical " + + "artist unwrap; 2.0 = noticeably stretched. Keep this short — every entry multiplies the " + + "grid size.")] + public float[] stretchThresholdVariants = { 1.5f }; + + [Tooltip("Call ResetPipelineState between sweep cells so each cell starts from the " + + "unmodified FBX meshes. Disable only for debugging a single cell.")] + public bool resetBetweenRuns = true; + } + + [Tooltip("Parameter sweep driven by LightmapTransferTool.ExecSweep (Run Sweep button).")] + public SweepMatrix sweep = new SweepMatrix(); + } + +#if UNITY_EDITOR + [CustomEditor(typeof(TestSuiteAsset))] + sealed class TestSuiteAssetEditor : Editor + { + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + var asset = (TestSuiteAsset)target; + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("Quick actions", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Open a case to select the FBX LODGroup in the scene; wire it into " + + "LightmapTransferTool manually from there (no automatic scene spawn).", + MessageType.Info); + + for (int i = 0; i < asset.cases.Count; i++) + { + var tc = asset.cases[i]; + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField($"{i}: {tc.label}", GUILayout.MinWidth(120)); + using (new EditorGUI.DisabledScope(tc.fbxAsset == null)) + { + if (GUILayout.Button("Ping FBX", GUILayout.Width(80))) + EditorGUIUtility.PingObject(tc.fbxAsset); + } + } + } + } + } +#endif +} diff --git a/Editor/Settings/TestSuiteAsset.cs.meta b/Editor/Settings/TestSuiteAsset.cs.meta new file mode 100644 index 00000000..21ee448a --- /dev/null +++ b/Editor/Settings/TestSuiteAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 078c3c54fa1e482b963cecfea2ef1848 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ShellQuality.cs b/Editor/ShellQuality.cs new file mode 100644 index 00000000..2e09dda1 --- /dev/null +++ b/Editor/ShellQuality.cs @@ -0,0 +1,168 @@ +// ShellQuality.cs — Per-shell UV parameterization quality metrics. +// Sander L² stretch (Sander/Snyder/Gortler/Hoppe 2001) is the industry- +// standard area-weighted RMS of per-triangle isometric distortion. +// 1.0 = isometric; 1.5 = mild; 2.0+ = noticeable; 3.0+ = severe. + +using System; +using UnityEngine; +using System.Collections.Generic; + +namespace SashaRX.UnityMeshLab +{ + internal static class ShellQuality + { + /// + /// Sander L² stretch for a single shell (area-weighted RMS of + /// per-triangle √((σ1² + σ2²) / 2), where σ1,σ2 are singular values + /// of the Jacobian UV → local-3D-tangent-plane for each triangle). + /// Returns 1.0 if all triangles are isometric, >1 otherwise. + /// Returns float.PositiveInfinity if any triangle has degenerate UV + /// (zero UV area but non-zero 3D area — infinite stretch). + /// Returns float.NaN if shell has no usable triangles. + /// + internal static float ComputeL2Stretch( + UvShell shell, + int[] globalTris, + Vector3[] positions, + float[] uvFlat) + { + if (shell?.faceIndices == null) return float.NaN; + int uvLen = uvFlat.Length; + int posLen = positions.Length; + + double sumArea = 0.0; + double sumAreaTimesS2 = 0.0; + int countValid = 0; + bool hasInfinite = false; + + foreach (int faceIdx in shell.faceIndices) + { + int t0 = faceIdx * 3; + if ((uint)(t0 + 2) >= (uint)globalTris.Length) continue; + int i0 = globalTris[t0]; + int i1 = globalTris[t0 + 1]; + int i2 = globalTris[t0 + 2]; + if ((uint)i0 >= (uint)posLen) continue; + if ((uint)i1 >= (uint)posLen) continue; + if ((uint)i2 >= (uint)posLen) continue; + int u0 = i0 * 2, u1 = i1 * 2, u2 = i2 * 2; + if ((uint)(u0 + 1) >= (uint)uvLen) continue; + if ((uint)(u1 + 1) >= (uint)uvLen) continue; + if ((uint)(u2 + 1) >= (uint)uvLen) continue; + + Vector3 p0 = positions[i0], p1 = positions[i1], p2 = positions[i2]; + Vector2 uvA = new Vector2(uvFlat[u0], uvFlat[u0 + 1]); + Vector2 uvB = new Vector2(uvFlat[u1], uvFlat[u1 + 1]); + Vector2 uvC = new Vector2(uvFlat[u2], uvFlat[u2 + 1]); + + // 3D area + Vector3 e1_3D = p1 - p0; + Vector3 e2_3D = p2 - p0; + Vector3 cross3D = Vector3.Cross(e1_3D, e2_3D); + float area3DTimes2 = cross3D.magnitude; + if (area3DTimes2 < 1e-12f) continue; // degenerate 3D triangle + + // UV signed area + Vector2 e1_UV = uvB - uvA; + Vector2 e2_UV = uvC - uvA; + float areaUVTimes2 = Mathf.Abs(e1_UV.x * e2_UV.y - e1_UV.y * e2_UV.x); + + // Project 3D triangle into its own tangent plane (q0=0, q1 along x). + float len_e1_3D = e1_3D.magnitude; + Vector3 e1hat = e1_3D / len_e1_3D; + Vector3 nhat = cross3D / area3DTimes2; + Vector3 e2hat = Vector3.Cross(nhat, e1hat); + Vector2 q1 = new Vector2(len_e1_3D, 0f); + Vector2 q2 = new Vector2(Vector3.Dot(e2_3D, e1hat), Vector3.Dot(e2_3D, e2hat)); + + if (areaUVTimes2 < 1e-12f) + { + // UV degenerate but 3D not → infinite stretch + hasInfinite = true; + sumArea += 0.5 * area3DTimes2; + continue; + } + + // J: 2x2 matrix such that J * (uvB - uvA) = q1 - 0, J * (uvC - uvA) = q2 - 0. + // Solve via direct inversion of 2x2. + // [J11 J12] [e1_UV.x] = q1.x = len_e1_3D + // [J21 J22] [e1_UV.y] 0 + // [J11 J12] [e2_UV.x] = q2.x + // [J21 J22] [e2_UV.y] q2.y + // + // Stack columns of UV edges into M_UV (2x2), same for M_q (2x2). + // J · M_UV = M_q → J = M_q · M_UV^(-1) + float mUV_det = e1_UV.x * e2_UV.y - e1_UV.y * e2_UV.x; + if (Mathf.Abs(mUV_det) < 1e-12f) + { + hasInfinite = true; + sumArea += 0.5 * area3DTimes2; + continue; + } + float inv = 1f / mUV_det; + // M_UV^(-1) = (1/det) * [[e2.y, -e2.x],[-e1.y, e1.x]] + float invUV_a = e2_UV.y * inv; + float invUV_b = -e2_UV.x * inv; + float invUV_c = -e1_UV.y * inv; + float invUV_d = e1_UV.x * inv; + // M_q = [[q1.x, q2.x], [q1.y, q2.y]] = [[len, q2.x], [0, q2.y]] + float J11 = len_e1_3D * invUV_a + q2.x * invUV_c; + float J12 = len_e1_3D * invUV_b + q2.x * invUV_d; + float J21 = 0f + q2.y * invUV_c; + float J22 = 0f + q2.y * invUV_d; + + // 2x2 SVD: singular values σ1,σ2 of J = sqrt(eigenvalues of J^T J). + // J^T J entries: + float a = J11 * J11 + J21 * J21; // (J^T J)[0,0] + float b = J11 * J12 + J21 * J22; // (J^T J)[0,1] == [1,0] + float c = J12 * J12 + J22 * J22; // (J^T J)[1,1] + // Eigenvalues of [[a,b],[b,c]]: (a+c)/2 ± sqrt(((a-c)/2)² + b²) + float tr = a + c; + float disc = (a - c) * (a - c) * 0.25f + b * b; + float sqdisc = Mathf.Sqrt(Mathf.Max(0f, disc)); + float lam1 = tr * 0.5f + sqdisc; + float lam2 = Mathf.Max(0f, tr * 0.5f - sqdisc); + + // Triangle stretch² = (σ1² + σ2²) / (2 · σ1·σ2) — SCALE-INVARIANT + // condition-number metric. The naïve Sander L² ((σ1²+σ2²)/2) + // is sensitive to absolute Jacobian magnitude, which is set + // by the upstream texel-density pass and has nothing to do + // with the shape distortion we actually want to measure. + // + // For an isometric triangle (σ1=σ2): metric = 1. + // For a 2:1 stretch: metric ≈ 1.118. + // For a 5:1 stretch: metric ≈ 1.61. + // For a 10:1 stretch: metric ≈ 2.24. + // For an N:1 stretch (large N): metric ≈ N/√2. + // + // lam1·lam2 = σ1²·σ2² = det(J)². Take sqrt → |det(J)| = σ1·σ2. + double detSq = (double)lam1 * (double)lam2; + if (detSq < 1e-30) + { + // J is rank-deficient — UV triangle collapsed to a line. + // Infinite stretch; flag the shell as severely broken. + hasInfinite = true; + sumArea += 0.5 * area3DTimes2; + continue; + } + double sigmaProduct = Math.Sqrt(detSq); + float triS2 = (float)((double)tr / (2.0 * sigmaProduct)); + + double a3d = 0.5 * area3DTimes2; + sumArea += a3d; + sumAreaTimesS2 += a3d * triS2; + countValid++; + } + + if (countValid == 0) + { + if (hasInfinite) return float.PositiveInfinity; + return float.NaN; + } + + float l2 = (float)System.Math.Sqrt(sumAreaTimesS2 / sumArea); + if (hasInfinite) return Mathf.Max(l2, 1000f); // signal heavy stretch + return l2; + } + } +} diff --git a/Editor/ShellQuality.cs.meta b/Editor/ShellQuality.cs.meta new file mode 100644 index 00000000..ddfad78e --- /dev/null +++ b/Editor/ShellQuality.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a3f4b6c0d2e4a8eb6f1c9d8a2b7e1f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/SymmetrySplitShells.cs b/Editor/SymmetrySplitShells.cs index 2307da6d..5062d736 100644 --- a/Editor/SymmetrySplitShells.cs +++ b/Editor/SymmetrySplitShells.cs @@ -31,6 +31,16 @@ struct ThresholdSet static ThresholdMode s_thresholdMode = ThresholdMode.LegacyFixed; static bool s_adaptiveModeLogged; + // ── Benchmark counters (reset by the caller, read after Split/SplitWithParams) ── + /// + /// Number of shells matched via the descriptor-distance fallback during the most + /// recent call. Read after Split completes; reset + /// by the caller (e.g. BenchmarkRecorder) before each pipeline run. + /// + public static int LastFallbackCount; + /// Total number of shells split across all Split*/SplitWithParams calls since the counter was reset. + public static int LastTotalSplitCount; + /// /// Controls threshold strategy for symmetry split detection. /// Default is LegacyFixed for backward compatibility. @@ -112,6 +122,7 @@ public static int Split(Mesh mesh, List shells, float separationThresho foreach (var sp in splits) totalSplit += ApplyBinarySplit(mesh, shells, sp.shellIndex, sp.axis, sp.splitThreshold, separationThreshold); + LastTotalSplitCount += totalSplit; return totalSplit; } @@ -257,7 +268,8 @@ public static int Split(Mesh mesh, List shells, out List o } } - UvtLog.Info($"[SymSplit] Split params: total={outParams.Count}, N-fold={nFoldParamsCount}, binary={binaryParamsCount}; applied splits total={totalSplit}"); + UvtLog.Info(UvtLog.Category.SymSplit, $"Split params: total={outParams.Count}, N-fold={nFoldParamsCount}, binary={binaryParamsCount}; applied splits total={totalSplit}"); + LastTotalSplitCount += totalSplit; return totalSplit; } @@ -346,13 +358,14 @@ public static int SplitWithParams(Mesh mesh, List shells, List= 0) { usedFallback = true; - string fallbackTag = p.foldCount == 2 ? "[SymSplit][PrescribedBinary]" : "[SymSplit]"; - UvtLog.Warn($"{fallbackTag} SplitWithParams: fallback descriptor match for sourceShellId={p.sourceShellId}, " + + LastFallbackCount++; + string fallbackTag = p.foldCount == 2 ? "PrescribedBinary " : ""; + UvtLog.Warn(UvtLog.Category.SymSplit, $"{fallbackTag}SplitWithParams: fallback descriptor match for sourceShellId={p.sourceShellId}, " + $"groupId={p.sourceGroupId}, signature={p.sourceShellSignature}, targetShell={bestShell}, distance={bestDistance:F4}"); } else { - UvtLog.Verbose($"[SymSplit] SplitWithParams: no matching target shell for sourceShellId={p.sourceShellId}, N={p.foldCount}"); + UvtLog.Verbose(UvtLog.Category.SymSplit, $"SplitWithParams: no matching target shell for sourceShellId={p.sourceShellId}, N={p.foldCount}"); continue; } } @@ -408,11 +421,12 @@ public static int SplitWithParams(Mesh mesh, List shells, ListTrue if the mesh carries a non-empty tangent stream matching its vertex count. + internal static bool HasTangents(Mesh mesh) + { + if (mesh == null) return false; + var t = mesh.tangents; + return t != null && t.Length > 0 && t.Length == mesh.vertexCount; + } + + /// + /// Validate handedness (w == ±1) and basic sanity (non-NaN, non-zero) on a tangent array. + /// Logs at Warn level when issues are found. Returns true when all tangents look valid. + /// + internal static bool ValidateTangentsW(Vector4[] tangents, string meshName, string operation) + { + if (tangents == null || tangents.Length == 0) return true; + + int nanCount = 0; + int zeroCount = 0; + int badWCount = 0; + int firstBadIdx = -1; + float worstWDev = 0f; + + for (int i = 0; i < tangents.Length; i++) + { + var t = tangents[i]; + + if (float.IsNaN(t.x) || float.IsNaN(t.y) || float.IsNaN(t.z) || float.IsNaN(t.w)) + { + nanCount++; + if (firstBadIdx < 0) firstBadIdx = i; + continue; + } + + float sqr = t.x * t.x + t.y * t.y + t.z * t.z; + if (sqr < MIN_SQR_LEN) + { + zeroCount++; + if (firstBadIdx < 0) firstBadIdx = i; + } + + float wDev = Mathf.Abs(Mathf.Abs(t.w) - 1f); + if (wDev > W_TOLERANCE) + { + badWCount++; + if (wDev > worstWDev) worstWDev = wDev; + if (firstBadIdx < 0) firstBadIdx = i; + } + } + + int total = nanCount + zeroCount + badWCount; + if (total == 0) return true; + + UvtLog.Warn($"[TBN] {operation} '{meshName}': tangent issues — " + + $"NaN={nanCount}, zero-length={zeroCount}, bad-W={badWCount} (worst |w|-1={worstWDev:F4}), " + + $"first bad index={firstBadIdx}"); + return false; + } + + /// + /// Mirror tangent presence from onto . + /// If original had no tangents, drops them on result; if it had them, validates W on result. + /// Warns when the original carried tangents but the result has none — that indicates a + /// pipeline step dropped TBN data and normal-map shading would regress on the saved mesh. + /// Safe to call with null arguments — no-op when either is null. + /// + internal static void EnforceTangentsMatchOriginal(Mesh result, Mesh original, string operation) + { + if (result == null || original == null) return; + + bool originalHasTbn = HasTangents(original); + bool resultHasTbn = HasTangents(result); + + if (!originalHasTbn && resultHasTbn) + { + UvtLog.Info($"[TBN] {operation} '{result.name}': source had no tangents — stripping {result.tangents.Length} tangents from result"); + result.tangents = null; + return; + } + + if (originalHasTbn && !resultHasTbn) + { + UvtLog.Warn($"[TBN] {operation} '{result.name}': source carried tangents but result has none — TBN dropped upstream, saved mesh will be missing tangent-space data"); + return; + } + + if (originalHasTbn && resultHasTbn) + ValidateTangentsW(result.tangents, result.name, operation); + } + + /// + /// After a weld/merge operation, verify the welded tangent stream is still consistent. + /// Flags if the original had tangents but the welded result lost them, and validates W + /// when both are present. Does not mutate the welded mesh. + /// + internal static void ValidateAfterWeld(Mesh original, Mesh welded, string operation) + { + if (original == null || welded == null) return; + + bool origHas = HasTangents(original); + bool weldedHas = HasTangents(welded); + + if (origHas && !weldedHas) + { + UvtLog.Warn($"[TBN] {operation} '{welded.name}': source had tangents but welded result has none — TBN dropped during weld"); + return; + } + + if (!weldedHas) return; + ValidateTangentsW(welded.tangents, welded.name, operation); + } + } +} diff --git a/Editor/TangentValidator.cs.meta b/Editor/TangentValidator.cs.meta new file mode 100644 index 00000000..9266d05c --- /dev/null +++ b/Editor/TangentValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6abe685f72564675a5b879a18ee936da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/TexelDensityNormalizer.cs b/Editor/TexelDensityNormalizer.cs new file mode 100644 index 00000000..f55c949a --- /dev/null +++ b/Editor/TexelDensityNormalizer.cs @@ -0,0 +1,232 @@ +// TexelDensityNormalizer.cs — pre-pack UV0 density correction. +// +// Single pass: per-shell uniform scale so each shell's UV-area is proportional +// to its 3D surface area, modulated by a global coverage budget that leaves +// slack for xatlas's bin-packing. +// +// Mutates the local uvFlat copy fed to xatlas. mesh.uv is never touched +// (project invariant). +// +// History: an earlier global non-uniform "unwrap aspect" pass scaled the +// overall UV0 bbox to 1:1 before this density pass. That pass was removed — +// xatlas does not require a 1:1 input UV0, and ARAP per-shell parameterization +// (when enabled) handles distortion at the correct level. Global anisotropic +// scale only ever fought ARAP's output. See repo history for the deletion +// rationale. + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class TexelDensityNormalizer + { + /// + /// Per-shell uniform-scale density correction. Each shell is rescaled + /// around its UV centroid so that UV-area / 3D-area is constant across + /// shells, optionally clamped to a coverage budget. + /// + /// Flat UV0 array (vertexCount * 2 floats). Mutated in place. + /// Per-mesh shells (read-only). + /// Mesh triangle index buffer. + /// Mesh vertex positions in mesh-local space. + /// Safety clamp on the per-shell scale. Default 0.1. + /// Safety clamp on the per-shell scale. Default 10. + /// When true the density target is the median per-shell density (robust + /// against outliers); when false it's the area-weighted average. Default false. + /// After per-shell density normalisation, total UV area is rescaled + /// to this fraction of [0,1]² so xatlas doesn't overflow the requested atlas resolution due to + /// bin-packing slack. Default 0.75. ≤0 or ≥1 disables the budget step. + /// Number of shells modified by the density pass. + internal static int Normalize( + float[] uvFlat, + List shells, + int[] tris, + Vector3[] positions, + float scaleMin = 0.1f, + float scaleMax = 10f, + bool medianDensity = false, + float targetCoverage = 0.75f) + { + if (uvFlat == null || shells == null || shells.Count == 0) return 0; + if (tris == null || positions == null) return 0; + if (scaleMin <= 0f) scaleMin = 0.1f; + if (scaleMax < scaleMin) scaleMax = scaleMin; + + int n = shells.Count; + int uvLen = uvFlat.Length; + int posLen = positions.Length; + int modifiedDensity = 0; + + // ── Density correction ── + // Measure UV area + 3D area per shell, then apply per-shell uniform + // scale so UV-area / 3D-area is constant across all shells, + // modulated by the coverage budget. + var area3DPerShell = new double[n]; + var areaUVPerShell = new double[n]; + double sumArea3D = 0.0; + double sumAreaUV = 0.0; + + for (int si = 0; si < n; si++) + { + var shell = shells[si]; + if (shell.faceIndices == null) continue; + double a3 = 0.0, au = 0.0; + foreach (int f in shell.faceIndices) + { + int t = f * 3; + if ((uint)(t + 2) >= (uint)tris.Length) continue; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)posLen || (uint)i1 >= (uint)posLen || (uint)i2 >= (uint)posLen) continue; + Vector3 p0 = positions[i0], p1 = positions[i1], p2 = positions[i2]; + a3 += Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5; + + int u0 = i0 * 2, u1 = i1 * 2, u2 = i2 * 2; + if ((uint)(u0 + 1) >= (uint)uvLen || + (uint)(u1 + 1) >= (uint)uvLen || + (uint)(u2 + 1) >= (uint)uvLen) continue; + double ax = uvFlat[u0], ay = uvFlat[u0 + 1]; + double bx = uvFlat[u1], by = uvFlat[u1 + 1]; + double cx = uvFlat[u2], cy = uvFlat[u2 + 1]; + au += Math.Abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) * 0.5; + } + area3DPerShell[si] = a3; + areaUVPerShell[si] = au; + sumArea3D += a3; + sumAreaUV += au; + } + + if (sumArea3D < 1e-12 || sumAreaUV < 1e-12) return 0; + + double densityTarget; + if (medianDensity) + { + var densities = new List(n); + for (int si = 0; si < n; si++) + { + double a3 = area3DPerShell[si]; + double au = areaUVPerShell[si]; + if (a3 > 1e-12 && au > 1e-12) + densities.Add(au / a3); + } + if (densities.Count == 0) return 0; + densities.Sort(); + densityTarget = densities[densities.Count / 2]; + } + else + { + densityTarget = sumAreaUV / sumArea3D; + } + if (densityTarget < 1e-12) return 0; + + // Coverage budget: scale density target so total post-normalize UV + // area equals targetCoverage fraction of [0,1]². + if (targetCoverage > 0f && targetCoverage < 1f && sumArea3D > 1e-12) + densityTarget = targetCoverage / sumArea3D; + + // ── Diagnostics: pre-normalize density distribution ── + // Density (au/a3) ratio across shells before normalisation. A + // wide spread means the artist UV0 had uneven density and the + // density pass has meaningful work to do; a narrow spread means + // the input was already near-uniform and the pass will look + // visually like "only a global scale was applied". + double preMin = double.MaxValue, preMax = 0.0, preSum = 0.0; + int preCount = 0; + for (int si = 0; si < n; si++) + { + double a3 = area3DPerShell[si]; + double au = areaUVPerShell[si]; + if (a3 < 1e-12 || au < 1e-12) continue; + double d = au / a3; + if (d < preMin) preMin = d; + if (d > preMax) preMax = d; + preSum += d; + preCount++; + } + double preMean = preCount > 0 ? preSum / preCount : 0.0; + double preRatio = (preMin > 1e-30 && preMax > 0.0) ? preMax / preMin : 0.0; + + // Scale-distribution + post-normalize density tracking. We log a + // summary so the user can verify the pass actually did per-shell + // work and didn't collapse into a single global scale. + double scaleMinSeen = double.MaxValue; + double scaleMaxSeen = 0.0; + double postMin = double.MaxValue, postMax = 0.0, postSum = 0.0; + int postCount = 0; + + for (int si = 0; si < n; si++) + { + double a3 = area3DPerShell[si]; + double au = areaUVPerShell[si]; + if (a3 < 1e-12 || au < 1e-12) continue; + + double desired = a3 * densityTarget; + double scaleSq = desired / au; + if (double.IsNaN(scaleSq) || double.IsInfinity(scaleSq) || scaleSq <= 0.0) continue; + float scale = (float)Math.Sqrt(scaleSq); + if (float.IsNaN(scale) || float.IsInfinity(scale)) continue; + scale = Mathf.Clamp(scale, scaleMin, scaleMax); + + // Track distribution before the early-skip (so a uniform-input + // run reports scale≈1 spread, proving it was a no-op rather + // than silently skipping with no signal). + if (scale < scaleMinSeen) scaleMinSeen = scale; + if (scale > scaleMaxSeen) scaleMaxSeen = scale; + + // Post-normalize density = (au * scale²) / a3 = desired / a3 = densityTarget. + // We re-derive from scale to detect clamp distortion. + double postDensity = (au * (double)scale * (double)scale) / a3; + if (postDensity < postMin) postMin = postDensity; + if (postDensity > postMax) postMax = postDensity; + postSum += postDensity; + postCount++; + + if (Mathf.Abs(scale - 1f) < 1e-4f) continue; + + var shell = shells[si]; + if (shell.vertexIndices == null || shell.vertexIndices.Count == 0) continue; + + // Uniform scale around the shell's UV centroid keeps the + // centroid fixed (so the layout doesn't drift) and doesn't + // distort the shape — ARAP's per-shell parameterization, if + // it ran, is preserved. + Vector2 c = Vector2.zero; + int cn = 0; + foreach (int v in shell.vertexIndices) + { + int idx = v * 2; + if ((uint)(idx + 1) >= (uint)uvLen) continue; + c.x += uvFlat[idx]; + c.y += uvFlat[idx + 1]; + cn++; + } + if (cn == 0) continue; + c.x /= cn; + c.y /= cn; + + foreach (int v in shell.vertexIndices) + { + int idx = v * 2; + if ((uint)(idx + 1) >= (uint)uvLen) continue; + uvFlat[idx] = c.x + (uvFlat[idx] - c.x) * scale; + uvFlat[idx + 1] = c.y + (uvFlat[idx + 1] - c.y) * scale; + } + modifiedDensity++; + } + + double postMean = postCount > 0 ? postSum / postCount : 0.0; + double postRatio = (postMin > 1e-30 && postMax > 0.0) ? postMax / postMin : 0.0; + double scaleSpread = (scaleMinSeen < double.MaxValue && scaleMinSeen > 1e-30) + ? scaleMaxSeen / scaleMinSeen : 1.0; + + UvtLog.Info(UvtLog.Category.Repack, + $"[Density] {modifiedDensity}/{n} shells rescaled, target={densityTarget:G3} | " + + $"pre au/a3: min={preMin:G3} max={preMax:G3} mean={preMean:G3} maxRatio={preRatio:F2}x | " + + $"post au/a3: min={postMin:G3} max={postMax:G3} mean={postMean:G3} maxRatio={postRatio:F2}x | " + + $"scale: min={scaleMinSeen:F3} max={scaleMaxSeen:F3} spread={scaleSpread:F2}x"); + + return modifiedDensity; + } + } +} diff --git a/Editor/TexelDensityNormalizer.cs.meta b/Editor/TexelDensityNormalizer.cs.meta new file mode 100644 index 00000000..59c2e96e --- /dev/null +++ b/Editor/TexelDensityNormalizer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: daaf9272abac4bafb90aaaad19d797b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 2d571d81..04bd3d3c 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -23,6 +23,26 @@ public class LightmapTransferTool : IUvTool public int ToolOrder => 0; public Action RequestRepaint { set => requestRepaint = value; } + static bool IsBruteForcePackAvailable(int internalOversample) + { + int oversample = internalOversample > 0 ? internalOversample : 1; + return oversample <= 1; + } + + static bool HasIncludedTransferTargets(IEnumerable entries, int sourceLodIndex) + { + if (entries == null) return false; + foreach (var e in entries) + { + if (e == null) continue; + if (!e.include) continue; + if (e.lodIndex == sourceLodIndex) continue; + if (e.originalMesh == null) continue; + return true; + } + return false; + } + // ── Internal tab ── enum Tab { Setup, Repack, Transfer } Tab tab = Tab.Setup; @@ -37,10 +57,27 @@ enum Tab { Setup, Repack, Transfer } Dictionary reportLodFoldouts = new Dictionary(); bool foldOutput = true; bool foldUv0Analysis, foldRepackSettings = true; + bool foldLogFilters; + bool foldValidationOverlay; bool splitTargetsInSymmetryStep; + bool skipSymmetrySplitStep; SymmetrySplitShells.ThresholdMode symSplitThresholdMode = SymmetrySplitShells.ThresholdMode.LegacyFixed; HashSet lastSymmetrySplitLods = new HashSet(); Vector2 reportScroll; + TestSuiteAsset sweepSuite; + + // Cache of the filterable UvtLog categories — enumerated once on type init + // to avoid per-repaint Enum.GetValues allocations inside the Log filters UI. + // Composite flag UvtLog.Category.All is filtered out; only single bits remain. + static readonly UvtLog.Category[] s_logCategories = BuildLogCategoryList(); + static UvtLog.Category[] BuildLogCategoryList() + { + var all = (UvtLog.Category[])Enum.GetValues(typeof(UvtLog.Category)); + var list = new List(all.Length); + foreach (var c in all) + if (c != UvtLog.Category.All) list.Add(c); + return list.ToArray(); + } // ── LOD generation ── int generateLodCount = 2; @@ -344,6 +381,38 @@ void DrawSetup() SymmetrySplitShells.CurrentThresholdMode = symSplitThresholdMode; ColorBtn(new Color(.2f,.75f,.95f), "Run Full Pipeline", 30, ExecFullPipeline); splitTargetsInSymmetryStep = EditorGUILayout.ToggleLeft("SymSplit target LODs (advanced)", splitTargetsInSymmetryStep); + skipSymmetrySplitStep = EditorGUILayout.ToggleLeft("Skip SymSplit step (diagnostic)", skipSymmetrySplitStep); + + // Parameter sweep: atlasRes × shellPad × borderPad from a TestSuiteAsset. + sweepSuite = (TestSuiteAsset)EditorGUILayout.ObjectField( + "Sweep suite", sweepSuite, typeof(TestSuiteAsset), false); + int cells = 0; + if (sweepSuite != null && sweepSuite.sweep != null) + { + var sm = sweepSuite.sweep; + int rL = sm.atlasResolutions?.Length ?? 0; + int pL = sm.shellPaddingPxVariants?.Length ?? 0; + int bL = sm.borderPaddingPxVariants?.Length ?? 0; + int arL = sm.arapIterationsVariants?.Length ?? 0; + int stL = sm.stretchThresholdVariants?.Length ?? 0; + cells = Mathf.Max(1, rL) * Mathf.Max(1, pL) * Mathf.Max(1, bL) + * Mathf.Max(1, arL) * Mathf.Max(1, stL); + } + using (new EditorGUILayout.HorizontalScope()) + { + using (new EditorGUI.DisabledScope(sweepSuite == null || cells == 0)) + { + if (GUILayout.Button($"Run Sweep ({cells})", GUILayout.Height(22))) + ExecSweep(sweepSuite.sweep); + } + if (GUILayout.Button(new GUIContent("Rebuild Report", + "Pick a BenchmarkReports/ folder and rebuild summary.csv / winner.json / index.html " + + "from the per-cell CSVs already on disk. Use this after a mid-sweep Unity crash."), + GUILayout.Height(22))) + { + ExecRebuildSweepReport(); + } + } EditorGUILayout.Space(6); H("Pipeline Settings"); @@ -358,6 +427,23 @@ void DrawSetup() EditorGUI.indentLevel--; } + EditorGUILayout.Space(4); + foldLogFilters = EditorGUILayout.Foldout(foldLogFilters, "Log filters", true); + if (foldLogFilters) + { + EditorGUI.indentLevel++; + UvtLog.Current = (UvtLog.Level)EditorGUILayout.EnumPopup("Level", UvtLog.Current); + var enabled = UvtLog.EnabledCategories; + for (int i = 0; i < s_logCategories.Length; i++) + { + var cat = s_logCategories[i]; + bool on = (enabled & cat) != 0; + bool newOn = EditorGUILayout.ToggleLeft(cat.ToString(), on); + if (newOn != on) UvtLog.SetCategoryEnabled(cat, newOn); + } + EditorGUI.indentLevel--; + } + EditorGUILayout.Space(4); foldUv0Analysis = EditorGUILayout.Foldout(foldUv0Analysis, "UV0 Analysis & Fix", true); if (foldUv0Analysis) @@ -394,9 +480,199 @@ void DrawRepack() if (foldRepackSettings) { EditorGUI.indentLevel++; - ctx.AtlasResolution = EditorGUILayout.IntField("Resolution", ctx.AtlasResolution); + ctx.RepackResolutionMode = (ResolutionMode)EditorGUILayout.EnumPopup( + new GUIContent("Resolution mode", + "Manual: you pick the atlas resolution (power of two) and " + + "the tool shows the effective texel density.\n" + + "Auto from texel density: you pick a target texels/meter " + + "and the tool sizes the atlas from total 3D surface area, " + + "rounded up to the next power of two and clamped to " + + "[64, 4096]. Padding stays in pixels in both modes."), + ctx.RepackResolutionMode); + + double total3DArea = MeshAreaHelper.ComputeTotal3DAreaMeters( + ctx.ForLod(ctx.SourceLodIndex) + .Where(e => e.originalMesh != null) + .Select(e => e.originalMesh)); + + if (ctx.RepackResolutionMode == ResolutionMode.Manual) + { + ctx.AtlasResolution = EditorGUILayout.IntField( + new GUIContent("Resolution", + "Atlas resolution in pixels. Power-of-two values are " + + "recommended (64, 128, 256, 512, 1024, 2048, 4096)."), + ctx.AtlasResolution); + int resForDisplay = Mathf.Max(1, ctx.AtlasResolution); + double effDensity = total3DArea > 0.0 + ? resForDisplay / System.Math.Sqrt(total3DArea / Mathf.Max(0.0001f, ctx.TargetUvCoverage)) + : 0.0; + EditorGUILayout.LabelField( + " ", + $"3D area: {total3DArea:F2} m² · effective ≈ {effDensity:F1} texels/m", + EditorStyles.miniLabel); + } + else + { + ctx.LightmapDensity = EditorGUILayout.Slider( + new GUIContent("Texels per meter", + "Target lightmap density. Tool computes the atlas " + + "resolution as ceil_pow2(sqrt(area × density² / coverage)), " + + "clamped to [64, 4096]. Typical values: 5-20 for props, " + + "1-5 for large environment pieces."), + ctx.LightmapDensity, 0.5f, 100f); + uint autoRes = MeshAreaHelper.ComputeAutoResolution( + total3DArea, ctx.LightmapDensity, ctx.TargetUvCoverage); + EditorGUILayout.LabelField( + " ", + $"3D area: {total3DArea:F2} m² · computed resolution: {autoRes} px", + EditorStyles.miniLabel); + } ctx.ShellPaddingPx = EditorGUILayout.IntSlider("Shell Padding", ctx.ShellPaddingPx, 0, 16); ctx.BorderPaddingPx = EditorGUILayout.IntSlider("Border Padding", ctx.BorderPaddingPx, 0, 16); + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Pre-pack", EditorStyles.miniBoldLabel); + ctx.NormalizeTexelDensity = EditorGUILayout.ToggleLeft( + new GUIContent("Normalize texel density", + "Per-shell UV0 rescale so UV-area is proportional to 3D surface area. " + + "Produces uniform texels-per-world-unit in the baked lightmap. " + + "Disable to preserve an existing baked-texture UV layout."), + ctx.NormalizeTexelDensity); + using (new EditorGUI.DisabledScope(!ctx.NormalizeTexelDensity)) + { + ctx.ReparameterizeStretchedShells = EditorGUILayout.ToggleLeft( + new GUIContent("Auto-fix stretched shells (ARAP)", + "Measure each shell's Sander L² stretch (UV vs 3D isometric distortion) and " + + "re-parameterize shells above the threshold via ARAP local-global. " + + "Replaces the previous per-shell-aspect affine hack — this works at the " + + "parameterization level, redistributing vertices rather than scaling the " + + "whole shell. Default ON; turn off only when preserving artist's exact UV0."), + ctx.ReparameterizeStretchedShells); + using (new EditorGUI.DisabledScope(!ctx.ReparameterizeStretchedShells)) + { + EditorGUI.indentLevel++; + ctx.StretchThreshold = EditorGUILayout.Slider( + new GUIContent(" L² stretch threshold", + "Shells with Sander L² stretch above this value are sent to ARAP. " + + "1.0 = isometric (perfect); 1.5 = typical artist unwrap (default); " + + "2.0 = noticeable stretch; 3.0+ = severely distorted. Lower fires " + + "ARAP on more shells; higher reserves it for clearly broken cases."), + ctx.StretchThreshold, 1.0f, 3.0f); + ctx.ArapIterations = EditorGUILayout.IntSlider( + new GUIContent(" ARAP iterations", + "Local-global iteration count. 50 is the default and matches 3ds Max's " + + "Relax-by-polygon-angles. 100-200 for highly curved/twisted strips."), + ctx.ArapIterations, 10, 200); + EditorGUI.indentLevel--; + } + ctx.ClampLightmapToUnit = EditorGUILayout.ToggleLeft( + new GUIContent("Clamp lightmap UV2 to [0,1]", + "Clamp every output UV2 coord into the unit square on both source " + + "(post-xatlas) and target (post-transfer) meshes. Cheap safety " + + "net against verts pushed a fraction of a texel outside by border " + + "padding, perturb fixups, or the topology enforcer — out-of-range " + + "UVs sample neighbouring atlas regions and bleed wrong light. " + + "Default ON."), + ctx.ClampLightmapToUnit); + ctx.TargetUvCoverage = EditorGUILayout.Slider( + new GUIContent("UV coverage budget", + "Fraction of [0,1]² atlas that normalized UVs sum to. " + + "Leaves slack for bin-packing inefficiency so the atlas doesn't " + + "grow past the requested resolution. Lower → safer fit, smaller " + + "charts; higher → tighter pack but risk of overflow + downscale."), + ctx.TargetUvCoverage, 0.3f, 0.95f); + ctx.PostPackDensityCorrection = EditorGUILayout.ToggleLeft( + new GUIContent("Post-pack density correction (experimental)", + "After xatlas pack, measure per-shell au2/a3 and shrink over-dense " + + "shells toward the median around their UV2 centroid. Compensates " + + "xatlas's per-chart ceil(extents) stretch which breaks uniform " + + "density for thin/anisotropic shells. Shrink-only (never expands) " + + "so neighbours can't collide. Leaves gaps in the atlas where " + + "shrunk shells used to be — trades coverage for density uniformity."), + ctx.PostPackDensityCorrection); + int[] osValues = { 1, 2, 4, 8, 16 }; + string[] osLabels = { "1× (default — off)", "2×", "4×", "8×", "16×" }; + int currentOs = Mathf.Max(1, ctx.InternalOversample); + int osIdx = 0; + for (int i = 0; i < osValues.Length; i++) + if (osValues[i] == currentOs) { osIdx = i; break; } + int newOsIdx = EditorGUILayout.Popup( + new GUIContent("Internal pack oversample", + "Internal xatlas atlas size = user resolution × this factor. " + + "xatlas's per-chart ceil(extents) stretch (xatlas.cpp:8345) " + + "breaks uniform density when shells have sub-pixel extents. " + + "Oversampling makes ceil rounding fractional. UV2 still " + + "normalized to [0,1]; Unity bakes at its own resolution.\n\n" + + "Default 4× brings density spread from ~14× down to ~2×.\n" + + "2× and above disable brute force pack " + + "automatically (search space becomes minutes-per-atlas).\n" + + "1× = off, original xatlas behaviour."), + osIdx, osLabels); + ctx.InternalOversample = osValues[Mathf.Clamp(newOsIdx, 0, osValues.Length - 1)]; + } + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("xatlas options", EditorStyles.miniBoldLabel); + bool bruteForceAvailable = IsBruteForcePackAvailable(ctx.InternalOversample); + using (new EditorGUI.DisabledScope(!bruteForceAvailable)) + { + ctx.XatlasBruteForce = EditorGUILayout.ToggleLeft( + new GUIContent("Brute force pack (1× only)", + "Run xatlas's exhaustive packer (slower, tighter atlas). Only active when Internal pack oversample is 1×; 2× and above use the heuristic packer automatically."), + ctx.XatlasBruteForce); + } + if (!bruteForceAvailable) + EditorGUILayout.LabelField("Effective packer", "Heuristic (oversample > 1)", EditorStyles.miniLabel); + ctx.XatlasRotateCharts = EditorGUILayout.ToggleLeft( + new GUIContent("Rotate charts", + "xatlas may rotate charts to fit better (recommended)."), + ctx.XatlasRotateCharts); + using (new EditorGUI.DisabledScope(!ctx.XatlasRotateCharts)) + { + ctx.XatlasRotateChartsToAxis = EditorGUILayout.ToggleLeft( + new GUIContent("Snap rotation to axis", + "Constrain chart rotation to 0/90/180/270° (preserves texel alignment)."), + ctx.XatlasRotateChartsToAxis); + } + ctx.XatlasBilinear = EditorGUILayout.ToggleLeft( + new GUIContent("Bilinear-safe padding", + "Pad each chart by 1 extra texel so bilinear sampling at runtime " + + "doesn't leak neighbor charts. Default ON for lightmap use."), + ctx.XatlasBilinear); + ctx.XatlasBlockAlign = EditorGUILayout.ToggleLeft( + new GUIContent("Block-align (BC/DXT)", + "Snap chart placement to 4×4 texel blocks. Required for compressed " + + "lightmaps (BC1/DXT) to avoid color bleed across block boundaries. " + + "Costs ~3-8% packing efficiency. Enable when shipping BC-compressed " + + "lightmaps; leave OFF for uncompressed progressive bakes."), + ctx.XatlasBlockAlign); + using (new EditorGUI.DisabledScope(!ctx.XatlasBlockAlign)) + { + int[] blockSizes = { 4, 5, 6, 8, 10, 12 }; + string[] blockLabels = { "4×4 (BC/ETC/DXT)", "5×5 (ASTC)", "6×6 (ASTC)", "8×8 (ASTC)", "10×10 (ASTC)", "12×12 (ASTC)" }; + int currentIdx = System.Array.IndexOf(blockSizes, ctx.XatlasBlockSize); + if (currentIdx < 0) currentIdx = 0; + int newIdx = EditorGUILayout.Popup( + new GUIContent("Block size", + "Compression block size. 4×4 covers BC1/BC3/BC5/BC7/ETC2/DXT*. " + + "ASTC variants (5..12) surface the intent — actual snap to >4 grids " + + "is a follow-up; at 4 behaviour matches xatlas exactly."), + currentIdx, blockLabels); + ctx.XatlasBlockSize = blockSizes[newIdx]; + } + ctx.XatlasMaxChartSize = EditorGUILayout.IntField( + new GUIContent("Max chart size (px)", + "Hard cap on individual chart dimension in atlas pixels. 0 = unbounded. " + + "A single oversized chart can force the atlas to grow past the " + + "requested resolution and trigger downscale; capping prevents that. " + + "Set to atlas resolution (or smaller) for a safe ceiling."), + ctx.XatlasMaxChartSize); + if (ctx.XatlasMaxChartSize < 0) ctx.XatlasMaxChartSize = 0; + ctx.XatlasTexelsPerUnit = EditorGUILayout.FloatField( + new GUIContent("Texels per UV unit", + "Override xatlas's auto-derived texel density (default 0 = auto-derive " + + "from atlas resolution). Manual value pins a fixed texels-per-UV-unit " + + "for projects that need consistent texel density across lightmaps."), + ctx.XatlasTexelsPerUnit); + if (ctx.XatlasTexelsPerUnit < 0f) ctx.XatlasTexelsPerUnit = 0f; EditorGUI.indentLevel--; } var src = ctx.ForLod(ctx.SourceLodIndex); @@ -481,6 +757,30 @@ void DrawTransfer() } EditorGUILayout.EndScrollView(); + EditorGUILayout.Space(4); + foldValidationOverlay = EditorGUILayout.Foldout(foldValidationOverlay, "Validation Overlay", true); + if (foldValidationOverlay) + { + EditorGUI.indentLevel++; + var mask = canvas != null ? canvas.ValidationFilterMask : TransferValidator.TriIssue.None; + bool changed = false; + changed |= ToggleIssueBit(ref mask, TransferValidator.TriIssue.Inverted, "Inverted"); + changed |= ToggleIssueBit(ref mask, TransferValidator.TriIssue.Stretched, "Stretched"); + changed |= ToggleIssueBit(ref mask, TransferValidator.TriIssue.ZeroArea, "ZeroArea"); + changed |= ToggleIssueBit(ref mask, TransferValidator.TriIssue.OutOfBounds, "OutOfBounds"); + changed |= ToggleIssueBit(ref mask, TransferValidator.TriIssue.Overlap, "Overlap"); + changed |= ToggleIssueBit(ref mask, TransferValidator.TriIssue.TexelDensity,"TexelDensity"); + if (changed && canvas != null) + { + canvas.ValidationFilterMask = mask; + requestRepaint?.Invoke(); + } + EditorGUILayout.LabelField( + mask == TransferValidator.TriIssue.None ? "(all triangles drawn)" : $"mask: {mask}", + EditorStyles.miniLabel); + EditorGUI.indentLevel--; + } + EditorGUILayout.Space(6); H("Apply UV2"); ColorBtn(new Color(.3f,.85f,.4f), "Apply UV2 to FBX", 26, ApplyUv2ToFbx); @@ -637,9 +937,315 @@ void ExecSymmetrySplit(bool includeTargets, float separationThreshold = 0.10f) requestRepaint?.Invoke(); } - void ExecFullPipeline() + void ExecFullPipeline() => ExecFullPipeline("FullPipeline"); + + void ExecFullPipeline(string runLabel) { if (ctx.LodGroup == null) return; + using var _bench = BenchmarkRecorder.NewRun(ctx, runLabel, + splitTargetsInSymmetryStep, symSplitThresholdMode); + BenchmarkRecorder.Current?.StageBegin("pipeline"); + bool completedSuccessfully = false; + try + { + completedSuccessfully = ExecFullPipelineCore(); + } + finally + { + BenchmarkRecorder.Current?.StageEnd("pipeline"); + // When the pipeline aborts early (user-cancel or exception) + // the per-mesh shellTransferResult / validation state is stale + // from a previous run — recording it would emit misleading + // metrics that taint sweep winners. Skip RecordMesh entirely + // in that case; the sweep aggregator already treats cells + // with no CSV row as failed. + if (completedSuccessfully && BenchmarkRecorder.Current != null) + foreach (var e in ctx.MeshEntries) + { + // Skip excluded entries: a user-deselected mesh has + // stale TransferResult/ValidationReport from a prior + // run and would surface as a failed row in sweep + // aggregates even though the pipeline never touched it. + if (!e.include) continue; + BenchmarkRecorder.Current.RecordMesh(e); + } + } + } + + /// + /// Run the full pipeline once per cell of a sweep matrix (cartesian product of + /// atlasResolutions × shellPaddingPxVariants × borderPaddingPxVariants × + /// arapIterationsVariants × stretchThresholdVariants). Each cell writes + /// its own CSV/JSON with runLabel "sweep_res{R}_pad{S}_bdr{B}_arap{N}_stretch{T}". + /// After the loop, if at least two cells completed, BenchmarkSweep.WriteAggregateReport + /// is invoked to score the cells and write a sweep_/summary.csv + + /// winner.json under BenchmarkReports/. Original ctx values are restored on exit. + /// + void ExecSweep(TestSuiteAsset.SweepMatrix sm) + { + if (ctx.LodGroup == null || sm == null) return; + var resArr = (sm.atlasResolutions != null && sm.atlasResolutions.Length > 0) + ? sm.atlasResolutions : new[] { ctx.AtlasResolution }; + var padArr = (sm.shellPaddingPxVariants != null && sm.shellPaddingPxVariants.Length > 0) + ? sm.shellPaddingPxVariants : new[] { ctx.ShellPaddingPx }; + var bdrArr = (sm.borderPaddingPxVariants != null && sm.borderPaddingPxVariants.Length > 0) + ? sm.borderPaddingPxVariants : new[] { ctx.BorderPaddingPx }; + var arapItersArr = (sm.arapIterationsVariants != null && sm.arapIterationsVariants.Length > 0) + ? sm.arapIterationsVariants + : new[] { ctx.ReparameterizeStretchedShells ? ctx.ArapIterations : 0 }; + var stretchArr = (sm.stretchThresholdVariants != null && sm.stretchThresholdVariants.Length > 0) + ? sm.stretchThresholdVariants : new[] { ctx.StretchThreshold }; + + int total = resArr.Length * padArr.Length * bdrArr.Length + * arapItersArr.Length * stretchArr.Length; + + // Snapshot ctx fields we mutate — restored unconditionally below. + int origRes = ctx.AtlasResolution; + int origPad = ctx.ShellPaddingPx; + int origBdr = ctx.BorderPaddingPx; + bool origArapOn = ctx.ReparameterizeStretchedShells; + int origArapIters = ctx.ArapIterations; + float origStretchThr = ctx.StretchThreshold; + // The sweep iterates an explicit atlasResolutions array. If the + // user left AutoFromTexelDensity selected, ExecRepackCore would + // overwrite ctx.AtlasResolution every cell and every row would + // record the auto value — collapsing the resolution dimension of + // the sweep. Force Manual for the duration of the sweep so each + // cell's `r` is the resolution xatlas actually packs at. + ResolutionMode origResMode = ctx.RepackResolutionMode; + ctx.RepackResolutionMode = ResolutionMode.Manual; + + // Aligned lists: writtenCsvPaths[i] is the CSV path produced by + // cellConfigs[i]. Passed to BenchmarkSweep after the loop completes. + var writtenCsvPaths = new List(total); + var cellConfigs = new List(total); + + // Pre-create the sweep_/ directory so the incremental + // aggregate after every successful cell can rewrite summary.csv / + // winner.json / index.html into a stable path. The final aggregate + // in the finally block uses the same directory. + // Millisecond precision — second-level stamps collided when an + // operator kicked off two sweeps in the same second (scripted + // runs, quick UI re-clicks). Without ms the second sweep would + // overwrite the first one's summary.csv / winner.json. + string sweepStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff", + System.Globalization.CultureInfo.InvariantCulture); + string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + string sweepDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports", + $"sweep_{sweepStamp}"); + try { System.IO.Directory.CreateDirectory(sweepDir); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] Could not pre-create sweep dir '{sweepDir}': {ex.Message}"); + sweepDir = null; + } + + int done = 0; + bool cancelled = false; + try + { + foreach (int r in resArr) + { + if (cancelled) break; + foreach (int s in padArr) + { + if (cancelled) break; + foreach (int b in bdrArr) + { + if (cancelled) break; + foreach (int arapIters in arapItersArr) + { + if (cancelled) break; + foreach (float stretchThr in stretchArr) + { + if (cancelled) break; + if (EditorUtility.DisplayCancelableProgressBar("Pipeline Sweep", + $"cell {done + 1}/{total}: res={r}, shellPad={s}, borderPad={b}, " + + $"arap={arapIters}, stretch={stretchThr:F2}", + (float)done / Mathf.Max(1, total))) + { + cancelled = true; + break; + } + + UvtLog.Verbose(UvtLog.Category.Benchmark, + $"[Sweep] cell {done + 1}/{total}: GC heap " + + $"{GC.GetTotalMemory(false) / (1024 * 1024)} MB"); + + ctx.AtlasResolution = r; + ctx.ShellPaddingPx = s; + ctx.BorderPaddingPx = b; + ctx.ReparameterizeStretchedShells = arapIters > 0; + if (arapIters > 0) ctx.ArapIterations = arapIters; + ctx.StretchThreshold = stretchThr; + + if (sm.resetBetweenRuns) ResetWorkingCopies(); + + // Encode stretch threshold as e.g. "1p50" — Sanitize() collapses '.' to '_' + // and that would break the recovery regex's _stretch(\d+p\d+)_ token. + int stretchHundredths = Mathf.RoundToInt(stretchThr * 100f); + string stretchTag = $"{stretchHundredths / 100}p{(stretchHundredths % 100):D2}"; + string label = $"sweep_res{r}_pad{s}_bdr{b}_arap{arapIters}_stretch{stretchTag}"; + string csvBefore = BenchmarkRecorder.LastWrittenCsvPath; + try + { + ExecFullPipeline(label); + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Cell {done + 1}/{total} threw: {ex.Message}"); + } + + // Capture the CSV the recorder just wrote (null if + // the pipeline aborted before WriteArtefacts ran). + string csvAfter = BenchmarkRecorder.LastWrittenCsvPath; + string csvPath = (csvAfter != null && csvAfter != csvBefore) + ? csvAfter : null; + + writtenCsvPaths.Add(csvPath); + cellConfigs.Add(new BenchmarkSweep.CellConfig + { + atlasRes = r, + shellPad = s, + borderPad = b, + arapEnabled = arapIters > 0, + arapIterations = arapIters, + stretchThreshold = stretchThr, + }); + done++; + + // Incremental aggregate: rewrite summary/winner/html after + // every successful cell so a mid-sweep Unity crash leaves + // usable reports. WriteAggregateReport accepts the same + // pre-created sweepDir each call and overwrites in place. + if (!string.IsNullOrEmpty(sweepDir)) + { + try + { + BenchmarkSweep.WriteAggregateReport( + writtenCsvPaths, cellConfigs, sweepDir); + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] Incremental aggregate failed: {ex.Message}"); + } + } + + // Release temporary meshes accumulated by the pipeline so + // the native atlas allocator and the managed GC heap don't + // balloon across 48+ cells (the original 30-cell crash was + // most likely OOM or fragmentation in this code path). + try + { + System.GC.Collect(); + UnityEngine.Resources.UnloadUnusedAssets(); + } + catch (Exception ex) + { + UvtLog.Verbose(UvtLog.Category.Benchmark, + $"[Sweep] Between-cell cleanup hiccup: {ex.Message}"); + } + } + } + } + } + } + } + finally + { + EditorUtility.ClearProgressBar(); + ctx.AtlasResolution = origRes; + ctx.ShellPaddingPx = origPad; + ctx.BorderPaddingPx = origBdr; + ctx.ReparameterizeStretchedShells = origArapOn; + ctx.ArapIterations = origArapIters; + ctx.StretchThreshold = origStretchThr; + ctx.RepackResolutionMode = origResMode; + UvtLog.Info(UvtLog.Category.Benchmark, + $"Sweep complete: {done}/{total} cells{(cancelled ? " (cancelled)" : "")}"); + + // Aggregate per-cell CSVs into a sweep_/summary.csv + + // winner.json. The incremental writer in the foreach above + // already keeps these in sync after every successful cell — + // this final call refreshes the same sweepDir to cover the + // edge case where the last cell threw before the incremental + // write executed. Safe to call even if individual cells + // produced no CSV (they are recorded as failed entries). + if (writtenCsvPaths.Count >= 1) + { + try + { + BenchmarkSweep.WriteAggregateReport(writtenCsvPaths, cellConfigs, sweepDir); + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Aggregate report failed: {ex.Message}"); + } + } + } + } + + /// + /// Prompts the user for a BenchmarkReports/ folder and asks + /// to reconstruct + /// summary.csv / winner.json / index.html from whatever per-cell CSVs + /// are still on disk after a mid-sweep Unity crash. Surfaces the result + /// (or a "no CSVs found" message) via . + /// + void ExecRebuildSweepReport() + { + string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + string defaultDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports"); + if (!System.IO.Directory.Exists(defaultDir)) defaultDir = projectRoot; + + string picked = EditorUtility.OpenFolderPanel( + "Pick BenchmarkReports/ folder to rebuild", defaultDir, ""); + if (string.IsNullOrEmpty(picked)) return; + + string outDir; + try + { + outDir = BenchmarkSweep.RebuildFromExistingCsvs(picked); + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Sweep] Rebuild threw: {ex.Message}"); + EditorUtility.DisplayDialog("Rebuild Sweep Report", + $"Rebuild failed: {ex.Message}", "OK"); + return; + } + + if (string.IsNullOrEmpty(outDir)) + { + EditorUtility.DisplayDialog("Rebuild Sweep Report", + "No matching CSVs were found in:\n" + picked + + "\n\nLook for files named *_sweep_resR_padS_bdrB_arapA_stretchT_*.csv.", + "OK"); + return; + } + + EditorUtility.DisplayDialog("Rebuild Sweep Report", + "Recovery report written to:\n" + outDir + + "\n\nOpen index.html in a browser for the per-run gallery.", + "OK"); + } + + /// + /// Run the auto-tune full pipeline. Returns true when the + /// pipeline ran end-to-end and the in-memory per-mesh state reflects + /// the just-completed run; returns false when the user + /// cancelled mid-flight so the caller can skip artefact recording + /// (stale state from a prior run would otherwise be written). + /// + bool ExecFullPipelineCore() + { string version = UnityEditor.PackageManager.PackageInfo .FindForAssembly(typeof(LightmapTransferTool).Assembly)?.version ?? "0.0.0"; UvtLog.Info($"[Pipeline] Starting full pipeline... (v{version})"); @@ -658,6 +1264,10 @@ void ExecFullPipeline() savedMeshes[e] = UnityEngine.Object.Instantiate(e.originalMesh); float[] separationConfigs = { 0.10f, 0.05f, 0.20f }; + bool hasTransferTargets = HasIncludedTransferTargets(ctx.MeshEntries, ctx.SourceLodIndex); + if (!hasTransferTargets) + UvtLog.Warn("[Pipeline] No included target LOD meshes; running source repack only and skipping transfer/auto-tune."); + int bestRejected = int.MaxValue; float bestCoverage = 0f; int bestConfigIdx = 0; @@ -691,6 +1301,8 @@ void ExecFullPipeline() kv.Key.originalMesh.name = kv.Value.name; kv.Key.wasSymmetrySplit = false; kv.Key.repackedMesh = null; + kv.Key.repackedAtlasWidth = 0; + kv.Key.repackedAtlasHeight = 0; kv.Key.transferredMesh = null; kv.Key.shellTransferResult = null; } @@ -701,8 +1313,11 @@ void ExecFullPipeline() ctx.HasTransfer = false; } - // 3. SymSplit - ExecSymmetrySplit(splitTargetsInSymmetryStep, sepThresh); + // 3. SymSplit (skipped via diagnostic toggle to isolate xatlas packing) + if (!skipSymmetrySplitStep) + ExecSymmetrySplit(splitTargetsInSymmetryStep, sepThresh); + else + UvtLog.Info(UvtLog.Category.SymSplit, "[Pipeline] SymSplit step SKIPPED by user toggle"); // 4. Repack var src = ctx.ForLod(ctx.SourceLodIndex); @@ -710,7 +1325,11 @@ void ExecFullPipeline() else ExecRepack(src); // 5. Transfer - if (ctx.HasRepack) ExecTransferAll(); + if (ctx.HasRepack && hasTransferTargets) ExecTransferAll(); + else if (ctx.HasRepack) ctx.HasTransfer = false; + + if (!hasTransferTargets) + break; // Evaluate quality int totalRejected = 0; @@ -791,7 +1410,7 @@ void ExecFullPipeline() if (cancelled) { requestRepaint?.Invoke(); - return; + return false; } if (separationConfigs.Length > 1 && bestConfigIdx > 0) UvtLog.Info($"[Pipeline] Auto-tune: selected config #{bestConfigIdx} " + @@ -799,12 +1418,51 @@ void ExecFullPipeline() UvtLog.Info("[Pipeline] Complete."); requestRepaint?.Invoke(); + return true; } void ExecRepack(List entries) { if (entries.Count == 0) return; - UvtLog.Info($"[Repack] {entries.Count} meshes, res={ctx.AtlasResolution}, pad={ctx.ShellPaddingPx}, bdr={ctx.BorderPaddingPx}"); + using var _bench = BenchmarkRecorder.NewRun(ctx, "Repack", + splitTargetsInSymmetryStep, symSplitThresholdMode); + // Mirror the ownership guard ExecTransferAll uses: only the + // outermost benchmark session writes per-mesh rows. A nested + // ExecRepack inside ExecFullPipeline / sweep would otherwise + // double-record (the outer run already records all meshes at + // its own end), inflating CSV/JSON aggregates and breaking + // sweep comparisons. + bool ownsSession = _bench is BenchmarkRecorder; + BenchmarkRecorder.Current?.StageBegin("repack"); + try { ExecRepackCore(entries); } + finally + { + BenchmarkRecorder.Current?.StageEnd("repack"); + if (ownsSession && BenchmarkRecorder.Current != null) + foreach (var e in entries) + BenchmarkRecorder.Current.RecordMesh(e); + } + } + + void ExecRepackCore(List entries) + { + uint resolvedResolution = (uint)ctx.AtlasResolution; + if (ctx.RepackResolutionMode == ResolutionMode.AutoFromTexelDensity) + { + double area = MeshAreaHelper.ComputeTotal3DAreaMeters( + entries.Where(e => e.originalMesh != null).Select(e => e.originalMesh)); + resolvedResolution = MeshAreaHelper.ComputeAutoResolution( + area, ctx.LightmapDensity, ctx.TargetUvCoverage); + UvtLog.Info( + $"[Repack] Auto-resolution: area={area:F2} m², density={ctx.LightmapDensity:F2} tex/m, " + + $"coverage={ctx.TargetUvCoverage:F2} → {resolvedResolution} px"); + } + // Stamp the recorder with the resolution xatlas will actually use, + // not the raw UI value — relevant for AutoFromTexelDensity mode + // where the resolved value can differ by an octave from the user + // setting. + BenchmarkRecorder.Current?.SetResolvedAtlasResolution((int)resolvedResolution); + UvtLog.Info($"[Repack] {entries.Count} meshes, res={resolvedResolution}, pad={ctx.ShellPaddingPx}, bdr={ctx.BorderPaddingPx}"); var validEntries = new List(); var meshCopies = new List(); foreach (var e in entries) @@ -820,9 +1478,25 @@ void ExecRepack(List entries) if (meshCopies.Count == 0) return; var opts = RepackOptions.Default; - opts.resolution = (uint)ctx.AtlasResolution; + opts.resolution = resolvedResolution; opts.padding = (uint)ctx.ShellPaddingPx; opts.borderPadding = (uint)ctx.BorderPaddingPx; + opts.bruteForce = ctx.XatlasBruteForce; + opts.rotateCharts = ctx.XatlasRotateCharts; + opts.rotateChartsToAxis = ctx.XatlasRotateChartsToAxis; + opts.normalizeTexelDensity = ctx.NormalizeTexelDensity; + opts.reparameterizeStretchedShells = ctx.ReparameterizeStretchedShells; + opts.stretchThreshold = ctx.StretchThreshold; + opts.arapIterations = ctx.ArapIterations; + opts.clampLightmapToUnit = ctx.ClampLightmapToUnit; + opts.targetUvCoverage = ctx.TargetUvCoverage; + opts.postPackDensityCorrection = ctx.PostPackDensityCorrection; + opts.internalOversample = ctx.InternalOversample > 0 ? ctx.InternalOversample : 1; + opts.maxChartSize = ctx.XatlasMaxChartSize; + opts.bilinear = ctx.XatlasBilinear; + opts.blockAlign = ctx.XatlasBlockAlign; + opts.blockSize = ctx.XatlasBlockSize; + opts.texelsPerUnit = ctx.XatlasTexelsPerUnit; var results = XatlasRepack.RepackMulti(meshCopies.ToArray(), opts); for (int i = 0; i < validEntries.Count; i++) @@ -831,9 +1505,13 @@ void ExecRepack(List entries) { UvtLog.Error("[Repack] " + validEntries[i].renderer.name + ": " + results[i].error); UnityEngine.Object.DestroyImmediate(meshCopies[i]); + validEntries[i].repackedAtlasWidth = 0; + validEntries[i].repackedAtlasHeight = 0; continue; } validEntries[i].repackedMesh = meshCopies[i]; + validEntries[i].repackedAtlasWidth = results[i].atlasWidth; + validEntries[i].repackedAtlasHeight = results[i].atlasHeight; } ctx.HasRepack = true; @@ -856,15 +1534,40 @@ void ExecRepackPerMesh(List entries) void ExecTransferAll() { - accumulatedOverlapHints.Clear(); - accumulatedMatchHints.Clear(); - for (int li = 0; li < ctx.LodCount; li++) + using var _bench = BenchmarkRecorder.NewRun(ctx, "TransferAll", + splitTargetsInSymmetryStep, symSplitThresholdMode); + bool ownsSession = _bench is BenchmarkRecorder; + BenchmarkRecorder.Current?.StageBegin("transfer"); + try { - if (li == ctx.SourceLodIndex) continue; - ExecTransferLod(li); + if (!HasIncludedTransferTargets(ctx.MeshEntries, ctx.SourceLodIndex)) + { + ctx.HasTransfer = false; + UvtLog.Warn("[Transfer] No included target LOD meshes; transfer skipped."); + requestRepaint?.Invoke(); + return; + } + + accumulatedOverlapHints.Clear(); + accumulatedMatchHints.Clear(); + for (int li = 0; li < ctx.LodCount; li++) + { + if (li == ctx.SourceLodIndex) continue; + ExecTransferLod(li); + } + ctx.HasTransfer = true; + requestRepaint?.Invoke(); + } + finally + { + BenchmarkRecorder.Current?.StageEnd("transfer"); + if (ownsSession && BenchmarkRecorder.Current != null) + foreach (var e in ctx.MeshEntries) + { + if (!e.include) continue; + BenchmarkRecorder.Current.RecordMesh(e); + } } - ctx.HasTransfer = true; - requestRepaint?.Invoke(); } void ExecTransferLod(int tLod) @@ -903,7 +1606,9 @@ void ExecTransferLod(int tLod) var tr = GroupedShellTransfer.Transfer(tgtMesh, srcMesh, accumulatedOverlapHints.Count > 0 ? accumulatedOverlapHints : null, - accumulatedMatchHints.Count > 0 ? accumulatedMatchHints : null); + accumulatedMatchHints.Count > 0 ? accumulatedMatchHints : null, + srcEntry.repackedAtlasWidth > 0 ? (int)srcEntry.repackedAtlasWidth : 0, + srcEntry.repackedAtlasHeight > 0 ? (int)srcEntry.repackedAtlasHeight : 0); if (tr.uv2 == null) { UvtLog.Warn($"[Transfer] Failed for '{tgt.renderer.name}'"); continue; } // Accumulate overlap hints for subsequent LODs @@ -919,12 +1624,21 @@ void ExecTransferLod(int tLod) // Build output mesh with UV2 applied var om = UnityEngine.Object.Instantiate(tgtMesh); om.name = tgtMesh.name + "_uvTransfer"; + if (ctx.ClampLightmapToUnit) + { + int clamped = XatlasRepack.ClampUvsToUnit(tr.uv2); + if (clamped > 0) + UvtLog.Verbose(UvtLog.Category.Match, + $"Clamped {clamped} UV2 vert(s) into [0,1] on '{tgt.renderer.name}'"); + } om.SetUVs(1, new List(tr.uv2)); tgt.transferredMesh = om; tgt.shellTransferResult = tr; // Validation + BenchmarkRecorder.Current?.StageBegin("validate"); tgt.validationReport = TransferValidator.Validate(tgtMesh, tr.uv2, tr); + BenchmarkRecorder.Current?.StageEnd("validate"); float pct = tr.verticesTotal > 0 ? tr.verticesTransferred * 100f / tr.verticesTotal : 0; UvtLog.Info($"[Transfer] '{tgt.renderer.name}' LOD{tLod}: {tr.shellsMatched} shells, {pct:F0}% coverage"); @@ -1263,6 +1977,12 @@ bool TryBuildSidecarEntry(MeshEntry entry, Mesh resultMesh, out MeshUv2Entry sid 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. + // When tangents are present, validate the W (handedness) component. + TangentValidator.EnforceTangentsMatchOriginal(sidecarMesh, entry.fbxMesh, "Sidecar"); + Vector2[] auxiliaryUv = null; int auxiliaryTargetUvChannel = -1; if (hasAppliedAoTarget && aoUvChannel != 1) @@ -1864,7 +2584,7 @@ bool ExportFbxIsolatedCore( bool restoreMeshCompression = false; bool restoreMeshOptimization = false; ModelImporterMeshCompression originalMeshCompression = ModelImporterMeshCompression.Off; - int originalMeshOptimizationFlags = 0; + MeshOptimizationFlags originalMeshOptimizationFlags = 0; if (!isVariantExport) { srcImporter = AssetImporter.GetAtPath(sourceFbxPath) as ModelImporter; @@ -2395,6 +3115,13 @@ void ExportFbx(bool overwriteSource, FbxExportIntent intent) if (uv2Donor != null) MergeUvComponentFromDonor(exportMesh, uv2Donor, aoUvChannel, aoUvComponent); } + // TangentValidator (from #112) strips synthesised + // tangents on export when the source mesh had none. + // The duplicate `string meshName = ResolveExportMeshName(...)` + // that came in from origin/main is dropped — it's + // already declared up at the top of the loop body + // by the FbxExportIntent migration in this branch. + TangentValidator.EnforceTangentsMatchOriginal(exportMesh, entry.fbxMesh, "FBX Export"); meshReplacements[meshName] = exportMesh; if (entry.renderer != null) meshRendererTemplates[meshName] = entry.renderer; @@ -2467,6 +3194,7 @@ void ExportFbx(bool overwriteSource, FbxExportIntent intent) if (uv2Donor != null) MergeUvComponentFromDonor(exportMesh, uv2Donor, aoUvChannel, aoUvComponent); } + TangentValidator.EnforceTangentsMatchOriginal(exportMesh, entry.fbxMesh, "FBX Export"); newMf.sharedMesh = exportMesh; var mr = child.AddComponent(); if (lastLodRendererTemplate != null) @@ -2647,19 +3375,25 @@ void ExportFbx(bool overwriteSource, FbxExportIntent intent) for (int s = 0; s < srcCol.subMeshCount; s++) stripped.SetTriangles(srcCol.GetTriangles(s), s); stripped.RecalculateNormals(); - // Generate tangents from normals (no UVs to derive from) - var normals = stripped.normals; - var tangents = new Vector4[normals.Length]; - for (int ti = 0; ti < normals.Length; ti++) + // Only synthesize tangents when the source actually had them. + // Otherwise downstream tooling sees added TBN data that did + // not exist in the original FBX import. + if (TangentValidator.HasTangents(srcCol)) { - 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); + 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.tangents = tangents; stripped.RecalculateBounds(); colMf.sharedMesh = stripped; } @@ -3513,6 +4247,7 @@ void SaveAll() { Mesh m = GetResultMesh(e); if (m == null) continue; + TangentValidator.EnforceTangentsMatchOriginal(m, e.fbxMesh, "SaveAll"); string ap = AssetDatabase.GenerateUniqueAssetPath(p + "/" + m.name + ".asset"); AssetDatabase.CreateAsset(m, ap); n++; } @@ -3550,6 +4285,8 @@ void ResetWorkingCopies() e.meshFilter.sharedMesh = e.fbxMesh; if (e.transferredMesh != null) { UnityEngine.Object.DestroyImmediate(e.transferredMesh); e.transferredMesh = null; } if (e.repackedMesh != null) { UnityEngine.Object.DestroyImmediate(e.repackedMesh); e.repackedMesh = null; } + e.repackedAtlasWidth = 0; + e.repackedAtlasHeight = 0; if (e.originalMesh != null && e.originalMesh != e.fbxMesh) UnityEngine.Object.DestroyImmediate(e.originalMesh); if (e.fbxMesh != null) e.originalMesh = e.fbxMesh; e.shellTransferResult = null; @@ -4180,6 +4917,16 @@ public void OnDrawStatusBar() static void H(string t) { EditorGUILayout.Space(2); EditorGUILayout.LabelField(t, EditorStyles.boldLabel); } static void Warn(string t) { EditorGUILayout.HelpBox(t, MessageType.Warning); } + static bool ToggleIssueBit(ref TransferValidator.TriIssue mask, TransferValidator.TriIssue bit, string label) + { + bool on = (mask & bit) != 0; + bool newOn = EditorGUILayout.ToggleLeft(label, on); + if (newOn == on) return false; + if (newOn) mask |= bit; + else mask &= ~bit; + return true; + } + void ColorBtn(Color col, string l, int h, Action a) { var b = GUI.backgroundColor; GUI.backgroundColor = col; diff --git a/Editor/Tools/LodGenerationTool.cs b/Editor/Tools/LodGenerationTool.cs index 362dfd0a..91b4c742 100644 --- a/Editor/Tools/LodGenerationTool.cs +++ b/Editor/Tools/LodGenerationTool.cs @@ -89,64 +89,78 @@ public void OnDrawSidebar() EditorGUILayout.LabelField("LOD Generation", EditorStyles.boldLabel); EditorGUILayout.Space(4); - if (ctx.LodGroup == null) - { - // Try to detect LOD siblings from the current selection - var selected = Selection.activeGameObject; - var siblings = FindLodSiblings(selected); + DrawDetectAndCreate(ctx); + if (ctx.LodGroup == null) return; - if (siblings != null && siblings.Count > 0) - { - RefreshDetectedLodCache(selected, siblings); - EditorGUILayout.HelpBox("LOD objects detected — create a LODGroup to continue.", MessageType.Info); - EditorGUILayout.Space(4); - EditorGUILayout.LabelField("Detected LODs", EditorStyles.boldLabel); - foreach (var (go, lodIndex, rendererCount, triangleCount) in cachedDetectedLods) - { - EditorGUILayout.LabelField( - $" LOD{lodIndex}: {go.name} ({rendererCount} renderer{(rendererCount != 1 ? "s" : "")}, {triangleCount:N0} tris)", - EditorStyles.miniLabel); - } + DrawWorkflowHint(); + DrawExistingLodTable(out int sourceTris, out int lastExistingLod); + DrawSettingsPanel(ctx, sourceTris, lastExistingLod); + DrawResultsAndClear(); + } + + // ── Detect / create LODGroup section ── + // Used by both the standalone LOD Gen tab AND Prefab Builder's right + // panel (when no LODGroup is selected, both surfaces should show the + // "Create LODGroup" affordance instead of an empty Settings panel). + internal void DrawDetectAndCreate(UvToolContext sharedCtx) + { + if (sharedCtx == null) return; + ctx = sharedCtx; + if (sharedCtx.LodGroup != null) return; - EditorGUILayout.Space(6); - var bgc = GUI.backgroundColor; - GUI.backgroundColor = new Color(.4f, .8f, .4f); - if (GUILayout.Button("Create LODGroup", GUILayout.Height(28))) - CreateLodGroup(siblings); - GUI.backgroundColor = bgc; + var selected = Selection.activeGameObject; + var siblings = FindLodSiblings(selected); + if (siblings != null && siblings.Count > 0) + { + RefreshDetectedLodCache(selected, siblings); + EditorGUILayout.HelpBox("LOD objects detected — create a LODGroup to continue.", MessageType.Info); + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Detected LODs", EditorStyles.boldLabel); + foreach (var (go, lodIndex, rendererCount, triangleCount) in cachedDetectedLods) + { + EditorGUILayout.LabelField( + $" LOD{lodIndex}: {go.name} ({rendererCount} renderer{(rendererCount != 1 ? "s" : "")}, {triangleCount:N0} tris)", + EditorStyles.miniLabel); } - else if (selected != null && SelectionHasRenderers(selected)) + EditorGUILayout.Space(6); + var bgc = GUI.backgroundColor; + GUI.backgroundColor = new Color(.4f, .8f, .4f); + if (GUILayout.Button("Create LODGroup", GUILayout.Height(28))) + CreateLodGroup(siblings); + GUI.backgroundColor = bgc; + } + else if (selected != null && SelectionHasRenderers(selected)) + { + EditorGUILayout.HelpBox( + "No LOD naming detected, but child renderers found.\n" + + "Create a LODGroup with all renderers as LOD0.", + MessageType.Info); + EditorGUILayout.Space(6); + var bgc = GUI.backgroundColor; + GUI.backgroundColor = new Color(.4f, .8f, .4f); + if (GUILayout.Button("Add LOD Group", GUILayout.Height(28))) { - EditorGUILayout.HelpBox( - "No LOD naming detected, but child renderers found.\n" + - "Create a LODGroup with all renderers as LOD0.", - MessageType.Info); - EditorGUILayout.Space(6); - var bgc = GUI.backgroundColor; - GUI.backgroundColor = new Color(.4f, .8f, .4f); - if (GUILayout.Button("Add LOD Group", GUILayout.Height(28))) + var lodGroup = CreateLodGroupFromRenderers(selected); + if (lodGroup != null) { - var lodGroup = CreateLodGroupFromRenderers(selected); - if (lodGroup != null) - { - ctx.Refresh(lodGroup); - requestRepaint?.Invoke(); - UvtLog.Info($"[LOD Gen] Created LODGroup on '{selected.name}' with all renderers as LOD0."); - } + ctx.Refresh(lodGroup); + requestRepaint?.Invoke(); + UvtLog.Info($"[LOD Gen] Created LODGroup on '{selected.name}' with all renderers as LOD0."); } - GUI.backgroundColor = bgc; } - else - { - EditorGUILayout.HelpBox( - "Assign a LODGroup in the UV2 Transfer tab first.\n" + - "Or select a GameObject with a LOD suffix (e.g. MyObject_LOD0) to auto-detect LOD siblings.", - MessageType.Info); - } - return; + GUI.backgroundColor = bgc; } + else + { + EditorGUILayout.HelpBox( + "Assign a LODGroup in the UV2 Transfer tab first.\n" + + "Or select a GameObject with a LOD suffix (e.g. MyObject_LOD0) to auto-detect LOD siblings.", + MessageType.Info); + } + } - // ── Workflow hint ── + void DrawWorkflowHint() + { bool hasRepack = ctx.MeshEntries.Any(e => e.repackedMesh != null); if (!hasRepack) { @@ -157,11 +171,13 @@ public void OnDrawSidebar() "3. UV2 Transfer: Overwrite Source FBX (saves everything)", MessageType.Info); } + } - // ── LOD Polycount Table ── + void DrawExistingLodTable(out int sourceTris, out int lastExistingLod) + { + sourceTris = 0; + lastExistingLod = -1; EditorGUILayout.LabelField("Existing LODs", EditorStyles.boldLabel); - int sourceTris = 0; - int lastExistingLod = -1; for (int li = 0; li < ctx.LodCount; li++) { var ee = ctx.ForLod(li); @@ -183,13 +199,96 @@ public void OnDrawSidebar() $"{prefix}LOD{li}: {lodTris:N0} tris {lodVerts:N0} verts ({pct:F0}%)", isSrc ? EditorStyles.boldLabel : EditorStyles.miniLabel); } + } + + // ── Fine-tuning settings panel — only the simplifier weights, no + // count/ratios/Generate. Surfaced in Prefab Builder's right sidebar + // where LOD count is controlled by the Hierarchy "+ Add LOD" pending + // model, so the count + Generate flow there would just duplicate + // existing affordances. The standalone LOD Gen tab keeps the full + // panel via DrawSettingsPanel. + internal void DrawSimplifierSettingsPanel() + { + EditorGUILayout.LabelField("Simplifier weights", EditorStyles.miniBoldLabel); + generateTargetError = EditorGUILayout.Slider("Target Error", generateTargetError, 0.001f, 0.5f); + generateUv2Weight = EditorGUILayout.Slider("UV2 Weight", generateUv2Weight, 0f, 500f); + generateNormalWeight = EditorGUILayout.Slider("Normal Weight", generateNormalWeight, 0f, 10f); + generateLockBorder = EditorGUILayout.Toggle("Lock Border", generateLockBorder); + + if (generateTargetError < 0.1f && generateUv2Weight > 50f) + EditorGUILayout.HelpBox( + "Low Target Error + High UV2 Weight may prevent reaching target polygon count. " + + "Try: Target Error 0.1–0.3, UV2 Weight 10–30, Lock Border OFF.", + MessageType.Warning); + + EditorGUILayout.Space(2); + EditorGUILayout.HelpBox( + "Per-LOD ratio is taken from the Hierarchy row's quality slider " + + "(or the pending insert's slider) when Apply Changes commits.", + MessageType.None); + } + + // Read-only accessor for the simplifier settings configured via the + // sliders above. Prefab Builder's pending-insert / regenerate path + // calls this and overrides targetRatio with the row's per-LOD value. + internal MeshSimplifier.SimplifySettings GetSimplifierSettings(float targetRatio) + { + return new MeshSimplifier.SimplifySettings + { + targetRatio = targetRatio, + targetError = generateTargetError, + uv2Weight = generateUv2Weight, + normalWeight = generateNormalWeight, + lockBorder = generateLockBorder, + uvChannel = 1, + }; + } + + // ── Settings panel ── + // Renders the LOD-count slider, per-target ratio sliders, simplifier + // weights, and the Generate button. Designed for embedding into the + // standalone LOD Gen tab AND Prefab Builder's right column. + // The caller passes the active context so the tool's own ctx + // reference is retargeted before the Generate action fires. + internal void DrawSettingsPanel(UvToolContext sharedCtx) + { + if (sharedCtx == null) return; + ctx = sharedCtx; + if (ctx.LodGroup == null) + { + DrawDetectAndCreate(ctx); + return; + } + int sourceTris = 0, lastExistingLod = -1; + // Recompute source/last so the panel works without the stat table. + for (int li = 0; li < ctx.LodCount; li++) + { + var ee = ctx.ForLod(li); + if (ee.Count == 0) continue; + lastExistingLod = li; + if (li == ctx.SourceLodIndex) + { + int t = 0; + foreach (var e in ee) + { + Mesh m = e.repackedMesh ?? e.originalMesh ?? e.fbxMesh; + if (m != null) t += GetTriangleCount(m); + } + sourceTris = t; + } + } + DrawSettingsPanel(sharedCtx, sourceTris, lastExistingLod); + } + + void DrawSettingsPanel(UvToolContext sharedCtx, int sourceTris, int lastExistingLod) + { + ctx = sharedCtx; int startLod = lastExistingLod + 1; EditorGUILayout.Space(8); EditorGUILayout.LabelField($"Generate LOD{startLod}+", EditorStyles.boldLabel); - // ── Settings ── generateLodCount = EditorGUILayout.IntSlider("Count", generateLodCount, 1, 4); float lastRatio = 1f; @@ -243,36 +342,48 @@ public void OnDrawSidebar() if (GUILayout.Button($"Generate LOD{startLod}–LOD{startLod + generateLodCount - 1}", GUILayout.Height(30))) ExecGenerateLods(startLod); GUI.backgroundColor = bg; + } + + void DrawResultsAndClear() + { + if (lastResults.Count == 0 && generatedObjects.Count == 0) return; - // ── Results ── - if (lastResults.Count > 0 || generatedObjects.Count > 0) + int sourceTris = 0; + for (int li = 0; li < ctx.LodCount; li++) { - if (lastResults.Count > 0) + if (li != ctx.SourceLodIndex) continue; + foreach (var e in ctx.ForLod(li)) { - EditorGUILayout.Space(6); - EditorGUILayout.LabelField("Generated", EditorStyles.boldLabel); - foreach (var r in lastResults) - { - float pct = sourceTris > 0 ? (float)r.simplifiedTris / sourceTris * 100f : 0; - string warn = r.hitErrorLimit ? " ⚠" : ""; + Mesh m = e.repackedMesh ?? e.originalMesh ?? e.fbxMesh; + if (m != null) sourceTris += GetTriangleCount(m); + } + } + + if (lastResults.Count > 0) + { + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("Generated", EditorStyles.boldLabel); + foreach (var r in lastResults) + { + float pct = sourceTris > 0 ? (float)r.simplifiedTris / sourceTris * 100f : 0; + string warn = r.hitErrorLimit ? " ⚠" : ""; + EditorGUILayout.LabelField( + $" LOD{r.lodLevel}: {r.meshName} — {r.simplifiedTris:N0} tris ({pct:F0}%){warn}", + EditorStyles.miniLabel); + if (r.hitErrorLimit) EditorGUILayout.LabelField( - $" LOD{r.lodLevel}: {r.meshName} — {r.simplifiedTris:N0} tris ({pct:F0}%){warn}", + $" target {r.targetRatio:P0}, got {r.actualRatio:P0} — increase Target Error", EditorStyles.miniLabel); - if (r.hitErrorLimit) - EditorGUILayout.LabelField( - $" target {r.targetRatio:P0}, got {r.actualRatio:P0} — increase Target Error", - EditorStyles.miniLabel); - } } - - EditorGUILayout.Space(4); - var bgClear = GUI.backgroundColor; - GUI.backgroundColor = new Color(.9f, .3f, .3f); - string clearLabel = lastResults.Count > 0 ? "Clear Results" : "Clear Generated LODs"; - if (GUILayout.Button(clearLabel, GUILayout.Height(20))) - ClearGeneratedLods(); - GUI.backgroundColor = bgClear; } + + EditorGUILayout.Space(4); + var bgClear = GUI.backgroundColor; + GUI.backgroundColor = new Color(.9f, .3f, .3f); + string clearLabel = lastResults.Count > 0 ? "Clear Results" : "Clear Generated LODs"; + if (GUILayout.Button(clearLabel, GUILayout.Height(20))) + ClearGeneratedLods(); + GUI.backgroundColor = bgClear; } diff --git a/Editor/Tools/PrefabBuilderTool.cs b/Editor/Tools/PrefabBuilderTool.cs index d43a75e4..7fac57e5 100644 --- a/Editor/Tools/PrefabBuilderTool.cs +++ b/Editor/Tools/PrefabBuilderTool.cs @@ -1,16 +1,31 @@ // PrefabBuilderTool.cs — Prefab Builder tool (IUvTool tab). -// Provides 3D scene preview of mesh channels, edge topology, and problem areas. -// PR #1: preview modes only. PR #2: cleanup scan/fix migration. PR #3: LOD + collision management. +// Sidebar layout (PR-1): +// • Scene preview toolbar (Off / Vert Colors / Normals / Tangents / UV0-3 / Edges / Problems) +// • Hierarchy: Root + Apply Changes + per-Dummy blocks (LOD rows + COL rows + channel badges) +// • Legacy Collision / Split / Merge / Mesh Info / Edge / Problem sections +// +// Pending-changes model: clicking "+ Add LOD" or ✕ on an existing LOD row queues +// a pending operation; the prefab itself is untouched until the user clicks +// Apply Changes. Apply Changes commits, in order, Root/Dummy renames, pending +// deletes, pending inserts (creates GameObject + simplified mesh), then renumbers +// trailing _LOD{N} / _COL[_Hull{N}] suffixes. Regenerate (↻) currently still +// runs immediately — it will move into the right-side settings panel with a 3D +// preview in PR-2; for now regenerated rows expose a Discard (↶) button that +// restores the import-time fbxMesh. +// +// PR-2 will pull tool settings (LOD generation, transfer, collider, VC bake) into a +// right-side stack and migrate the legacy sections out of the sidebar. PR-3 adds the +// Build & Save bottom bar with pre-flight validation. The Build Pipeline / LOD Levels +// foldouts that lived here previously have been folded into the new Hierarchy view. using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEditor; -using UnityEditor.SceneManagement; namespace SashaRX.UnityMeshLab { - public class PrefabBuilderTool : IUvTool + public class PrefabBuilderTool : IUvTool, IUvToolRightSidebar { UvToolContext ctx; UvCanvasView canvas; @@ -37,11 +52,129 @@ enum PreviewMode PreviewMode previewMode = PreviewMode.None; PrefabBuilderPreview preview; - // ── Hierarchy editing state ── - Dictionary pendingNames; // instanceID → edited name + // ── Hierarchy view state ── + // pendingNames: instanceID → edited name (uncommitted; committed by Apply Names). + // lodQualitySliders: renderer instanceID → simplification target ratio. + // freshRendererIds: renderer instanceID set freshly created or regenerated + // since the last Apply Names. Drives the bright-orange "this is new" + // highlight so the user can spot just-inserted LODs in a busy hierarchy. + // hierarchyDummies: cached view model rebuilt from ctx; null = needs rebuild. + Dictionary pendingNames; + Dictionary lodQualitySliders; + HashSet freshRendererIds; + // Tracks the LODGroup the fresh-set is scoped to. When the user + // selects a different prefab the hub fires OnRefresh; we then know + // to discard the highlight from the previous prefab. Structural + // mutations on the SAME LODGroup (Insert/Delete) keep the set. + LODGroup freshTrackedLodGroup; bool hierarchyFoldout = true; + List hierarchyDummies; + + // ── Right sidebar state ── + // PrefabBuilderTool implements IUvToolRightSidebar so the hub renders + // a separate sidebar on the right edge of the window for our + // Settings stack. The sidebar hosts deferred regenerate parameters + // (LOD), collider config, UV2 transfer, vertex-color bake — each + // section is sourced from the existing tool's instance via + // UvToolHub.FindTool(). Tools are not duplicated — we just call + // into their existing settings UI. + bool rightPanelLodFoldout = true; + bool rightPanelColliderFoldout; + bool rightPanelTransferFoldout; + bool rightPanelVcBakeFoldout; + + // ── Per-chain foldout state ── + // Set of "|" keys that the + // user has collapsed. Default open (key absent). + HashSet collapsedChains; + + // ── Pending changes (deferred until "Apply Changes") ── + // Pending Insert: the user clicked "+ Add LOD after LODN" but the + // GameObject + simplified mesh aren't created until Apply Changes. + // The pending row renders inline in DrawDummyBlock with a PENDING + // badge and a quality slider the user can tweak before commit. + // Pending Delete: the user clicked ✕ on an existing LOD row. The + // renderer is marked for destruction but not actually removed + // until Apply Changes; clicking ✕ again reverts the mark. + // Pending inserts are slot-scoped (apply to the LODGroup as a whole, not + // to a single Dummy). Clicking "+ Add LOD after LOD{N}" in any Dummy + // block queues one entry; on Apply Changes we create a renderer in + // EVERY Dummy that has a LOD0 source mesh and splice them all into the + // new slot together. Otherwise inserting in Stove_Base alone would + // leave Stove_Cap with no coverage at the new slot, and Stove_Cap + // would silently disappear at that camera distance. + sealed class PendingInsert + { + public int afterLodIndex; // slot to insert after (-1 = at the very start) + // Captured at click time so the slot index can be re-resolved + // against the live LODGroup at Apply Changes time. Without this, + // applying a pending delete BEFORE the insert (in the same Apply + // batch) would shift slot indices and the insert would land in + // the wrong place. + public Renderer afterRenderer; + public float quality; // simplification target ratio chosen by the user + } + // Pending Wrap-chain-in-Dummy: captures the chain base name + the + // renderers that should be reparented into the new Dummy when the + // user clicks Apply Changes. Kept as a queue so the user can stack + // multiple wraps before committing, and cancel any queued wrap by + // clicking the chain action a second time. + sealed class PendingWrapChain + { + public string baseName; + public List renderers; + } + // Pending Add-empty-Dummy: just a name. Apply Changes creates the + // GameObject under Root. + sealed class PendingAddDummy + { + public string name; + } + List pendingInserts; + HashSet pendingDeleteRendererIds; + List pendingWrapChains; + List pendingAddDummies; + // Per-renderer backup of the mesh before the FIRST regenerate this + // session. Used by RowWasRegenerated / DiscardRegenerate so the + // affordance survives ctx.Refresh (which would otherwise rewrite + // MeshEntry.fbxMesh to point at the simplified mesh). + Dictionary regenBackupMeshes; + + sealed class HierarchyDummy + { + public Transform dummy; // container transform; equals root when flat + public bool isRoot; // true when the prefab has no separate dummy container + public List lods = new List(); + public List cols = new List(); + public bool foldout = true; + } + + sealed class HierarchyLodRow + { + public int lodIndex; + public Renderer renderer; + public Mesh mesh; + } + + sealed class HierarchyColRow + { + public Transform colTransform; // GameObject the collider is on (dummy itself or a _COL child). + public Collider collider; // Component reference (Mesh / Box / Capsule / Sphere…). + public Mesh mesh; // sharedMesh for MeshCollider, or sharedMesh of a _COL GO MeshFilter. + public string typeLabel; // "Mesh" / "Box" / "Capsule" / "Sphere" / fallback type name. + // True when this row represents a Collider component attached to the + // Dummy/Root GameObject itself (not a separate _COL child). Component + // rows can't be renamed by Rebuild Names and ✕ removes only the + // component instead of destroying the whole GameObject. + public bool isComponentOnly; + } + + // Accumulates which channels mutating operations touched since the last + // refresh. Drives the export pipeline (PR-3) so isolated re-save can be + // used when only data channels changed. + FbxExportIntent buildIntent = FbxExportIntent.None; - // ── Split/Merge state ── + // ── Split/Merge state (legacy, migrates to right panel in PR-2) ── struct SplitCandidate { public MeshEntry entry; @@ -58,33 +191,10 @@ struct MergeGroup List mergeCandidates; bool splitMergeFoldout; - // ── LOD management state ── - bool lodFoldout = true; - - // ── Build pipeline state ── - bool buildFoldout; - bool editInPrefabStage; - int buildLodCount = 2; - float[] buildLodRatios = { 0.5f, 0.25f, 0.125f, 0.0625f }; - float buildTargetError = 0.2f; - float buildUv2Weight = 20f; - float buildNormalWeight = 1f; - bool buildLockBorder = false; - List buildIssues; - Dictionary buildIssueFoldouts = - new Dictionary(); - - // Accumulates which channels Build pipeline mutated since the last - // refresh / save. Drives ExecBuildSave's FbxExportIntent so the - // narrow isolated re-save core can be used when only data channels - // changed, falling back to wide path (intent=All) only when - // hierarchy / LODGroup / materials / collision were touched. - FbxExportIntent buildIntent = FbxExportIntent.None; - - // ── Collision management state ── + // ── Collision state (legacy) ── bool collisionFoldout; - // ── Edge report cache ── + // ── Edge / problem report cache (legacy) ── struct MeshEdgeReport { public string meshName; @@ -92,7 +202,6 @@ struct MeshEdgeReport } List edgeReports; - // ── Problem scan cache ── struct ProblemSummary { public string meshName; @@ -110,6 +219,14 @@ public void OnActivate(UvToolContext ctx, UvCanvasView canvas) this.canvas = canvas; if (preview == null) preview = new PrefabBuilderPreview(); pendingNames = new Dictionary(); + lodQualitySliders = new Dictionary(); + freshRendererIds = new HashSet(); + pendingInserts = new List(); + pendingDeleteRendererIds = new HashSet(); + pendingWrapChains = new List(); + pendingAddDummies = new List(); + regenBackupMeshes = new Dictionary(); + collapsedChains = new HashSet(); } public void OnDeactivate() @@ -125,9 +242,28 @@ public void OnRefresh() edgeReports = null; problemSummaries = null; pendingNames?.Clear(); + lodQualitySliders?.Clear(); + + // Clear session-scoped state ONLY when the selected prefab's + // LODGroup itself has changed. Hub.OnGUI also fires OnRefresh on + // structural mutations within the same prefab (LodCount delta); + // those should keep the ★ NEW highlight and pending-changes + // queue so the user can spot the row they just added even after + // a Rebuild Names. + if (ctx == null || ctx.LodGroup != freshTrackedLodGroup) + { + freshRendererIds?.Clear(); + pendingInserts?.Clear(); + pendingDeleteRendererIds?.Clear(); + pendingWrapChains?.Clear(); + pendingAddDummies?.Clear(); + regenBackupMeshes?.Clear(); + freshTrackedLodGroup = ctx?.LodGroup; + } + + hierarchyDummies = null; splitCandidates = null; mergeCandidates = null; - buildIssues = null; buildIntent = FbxExportIntent.None; } @@ -149,23 +285,104 @@ public void OnDrawSidebar() DrawPreviewModeToolbar(); DrawHierarchySection(); - DrawLodManagementSection(); - DrawBuildPipelineSection(); DrawCollisionSection(); DrawSplitMergeSection(); DrawMeshInfo(); + if (edgeReports != null) DrawEdgeReportSection(); + if (problemSummaries != null) DrawProblemSummarySection(); + DrawEdgeLegend(); + } + + // ═══════════════════════════════════════════════════════════ + // Right sidebar — Settings stack hosted by the hub on the right + // edge of the window. Each foldout pulls its UI from the matching + // tool instance via UvToolHub.FindTool() so we don't duplicate + // the per-tool state. PR-2 lights up LOD Settings; the remaining + // sections are placeholders pending migration. + // ═══════════════════════════════════════════════════════════ + + public void OnDrawRightSidebar() + { + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Tool Settings", EditorStyles.boldLabel); + EditorGUILayout.Space(2); + + DrawRightPanelSection(ref rightPanelLodFoldout, "LOD Settings", DrawLodSettingsContent); + DrawRightPanelSection(ref rightPanelColliderFoldout, "Collider Settings", + () => EditorGUILayout.HelpBox("To be migrated from Collision tool in a follow-up commit.", MessageType.None)); + DrawRightPanelSection(ref rightPanelTransferFoldout, "Transfer Settings", + () => EditorGUILayout.HelpBox("To be migrated from UV2 Transfer tool in a follow-up commit.", MessageType.None)); + DrawRightPanelSection(ref rightPanelVcBakeFoldout, "Vertex Color Bake", + () => EditorGUILayout.HelpBox("To be migrated from Vertex Color Baking tool in a follow-up commit.", MessageType.None)); + } - if (edgeReports != null) - DrawEdgeReportSection(); + void DrawRightPanelSection(ref bool foldout, string title, System.Action drawContent) + { + EditorGUILayout.Space(4); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + foldout = EditorGUILayout.Foldout(foldout, title, true, EditorStyles.foldoutHeader); + if (foldout) + { + EditorGUILayout.Space(2); + drawContent?.Invoke(); + } + EditorGUILayout.EndVertical(); + } - if (problemSummaries != null) - DrawProblemSummarySection(); + void DrawLodSettingsContent() + { + var lodGen = FindLodGenerationTool(); + if (lodGen == null) + { + EditorGUILayout.HelpBox( + "LodGenerationTool instance not found in the hub.", + MessageType.Info); + return; + } + // Only the fine-tuning weights live here. LOD count + per-target + // ratios are managed via the Hierarchy "+ Add LOD" pending model + // (left sidebar). Showing the full LOD Gen settings panel here + // would duplicate that workflow. + lodGen.DrawSimplifierSettingsPanel(); + } - DrawEdgeLegend(); + // Resolve the singleton LodGenerationTool instance the Hub created + // when it discovered IUvTool implementations on activation. Iterating + // open windows is cheap; the hub is a single dockable EditorWindow. + static LodGenerationTool FindLodGenerationTool() + { + var hubs = Resources.FindObjectsOfTypeAll(); + if (hubs == null) return null; + foreach (var hub in hubs) + { + var t = hub != null ? hub.FindTool() : null; + if (t != null) return t; + } + return null; + } + + // Pull simplifier weights from the right-sidebar Settings panel + // (LodGenerationTool's tunables). When the LOD Gen tool isn't + // discoverable yet (e.g. during very early initialization), fall + // back to a known-safe preset so regenerate / insert still proceeds. + MeshSimplifier.SimplifySettings ResolveSimplifierSettings(float targetRatio) + { + var lodGen = FindLodGenerationTool(); + if (lodGen != null) + return lodGen.GetSimplifierSettings(targetRatio); + return new MeshSimplifier.SimplifySettings + { + targetRatio = targetRatio, + targetError = 0.1f, + uv2Weight = 0.5f, + normalWeight = 0.5f, + lockBorder = true, + uvChannel = 1, + }; } // ═══════════════════════════════════════════════════════════ - // Preview mode toolbar + // Preview mode toolbar (PR-4 will move this to viewport top bar) // ═══════════════════════════════════════════════════════════ void DrawPreviewModeToolbar() @@ -173,7 +390,6 @@ void DrawPreviewModeToolbar() EditorGUILayout.Space(4); EditorGUILayout.LabelField("Scene Preview", EditorStyles.miniLabel); - // Row 1: Off, Vert Colors, Normals, Tangents EditorGUILayout.BeginHorizontal(); DrawModeButton("Off", PreviewMode.None); DrawModeButton("Vert Colors", PreviewMode.VertexColors); @@ -181,7 +397,6 @@ void DrawPreviewModeToolbar() DrawModeButton("Tangents", PreviewMode.Tangents); EditorGUILayout.EndHorizontal(); - // Row 2: UV channels EditorGUILayout.BeginHorizontal(); DrawModeButton("UV0", PreviewMode.UV0); DrawModeButton("UV1", PreviewMode.UV1); @@ -189,7 +404,6 @@ void DrawPreviewModeToolbar() DrawModeButton("UV3", PreviewMode.UV3); EditorGUILayout.EndHorizontal(); - // Row 3: Edges, Problems EditorGUILayout.BeginHorizontal(); DrawModeButton("Edges", PreviewMode.EdgeWireframe); DrawModeButton("Problems", PreviewMode.ProblemAreas); @@ -208,7 +422,6 @@ void DrawModeButton(string label, PreviewMode mode) { if (previewMode == mode) { - // Toggle off preview.Restore(); previewMode = PreviewMode.None; edgeReports = null; @@ -261,7 +474,21 @@ void ActivateCurrentPreview() } // ═══════════════════════════════════════════════════════════ - // Hierarchy section with inline name editing + // Hierarchy section (NEW, PR-1) + // + // Shape: + // Root row: [name field] "Root" [Apply Names (green)] + // For each Dummy block: + // Foldout header [name field if non-root] + // Per LOD row: + // Row A: [name display] [+ Add LOD] [↻] [✕] + // Row B: LOD{N} Vv / Tt [channel badges] + // Row C: LOD{N} [— quality slider —] value + // "+ Add LOD" insert-row between LODs and at the bottom + // COL rows (different colour) with [name] [✕] + // + // Per user spec: only Root + Dummy names are user-editable; child names + // (LOD / COL) are auto-derived on Apply Names. ↑↓ reorder removed. // ═══════════════════════════════════════════════════════════ void DrawHierarchySection() @@ -270,1141 +497,1986 @@ void DrawHierarchySection() hierarchyFoldout = EditorGUILayout.Foldout(hierarchyFoldout, "Hierarchy", true); if (!hierarchyFoldout) return; - if (ctx.LodGroup == null && !ctx.StandaloneMesh) return; - - Transform root = ctx.LodGroup != null ? ctx.LodGroup.transform : null; - if (root == null) return; - - var colSet = new HashSet(MeshHygieneUtility.FindCollisionObjects(root)); - - // Root name (no indent) - DrawEditableName(root.gameObject, "Root", 0); + if (ctx.LodGroup == null && !ctx.StandaloneMesh) + { + EditorGUILayout.HelpBox("No LODGroup selected.", MessageType.Info); + return; + } - // Draw hierarchy respecting group containers - var drawn = new HashSet(); - foreach (Transform child in root) + if (ctx.LodGroup == null) { - if (child == null) continue; - if (colSet.Contains(child.gameObject)) continue; + EditorGUILayout.HelpBox("Standalone mesh: hierarchy editing requires LODGroup.", MessageType.Info); + return; + } - var childMf = child.GetComponent(); - bool isContainer = childMf == null && child.childCount > 0; + if (hierarchyDummies == null) RebuildHierarchyView(); + if (hierarchyDummies == null) return; - if (isContainer) - { - // Group container node - DrawEditableName(child.gameObject, "Group", 1); - drawn.Add(child.gameObject); + DrawRootRow(); - // Children inside container - foreach (Transform gc in child) - { - if (gc == null) continue; - var gcMf = gc.GetComponent(); - Mesh gcMesh = gcMf != null ? gcMf.sharedMesh : null; - if (gcMesh == null) continue; - - int lodIdx = GetLodIndexFromName(gc.name); - int verts = gcMesh.vertexCount; - int tris = MeshHygieneUtility.GetTriangleCount(gcMesh); - string suffix = lodIdx >= 0 - ? $"LOD{lodIdx} {verts:N0}v / {tris:N0}t" - : $"{verts:N0}v / {tris:N0}t"; - DrawEditableName(gc.gameObject, suffix, 2); - drawn.Add(gc.gameObject); - } - } - else if (childMf != null && childMf.sharedMesh != null) - { - // Direct mesh child (no container) - Mesh mesh = childMf.sharedMesh; - int lodIdx = GetLodIndexFromName(child.name); - int verts = mesh.vertexCount; - int tris = MeshHygieneUtility.GetTriangleCount(mesh); - string suffix = lodIdx >= 0 - ? $"LOD{lodIdx} {verts:N0}v / {tris:N0}t" - : $"{verts:N0}v / {tris:N0}t"; - DrawEditableName(child.gameObject, suffix, 1); - drawn.Add(child.gameObject); - } - } + // Re-check: DrawRootRow's Rebuild Names button can mutate state + // and null hierarchyDummies mid-frame. + if (hierarchyDummies == null) return; - // Collision objects - foreach (var colGo in colSet) + // Indent each Dummy block so the hierarchy reads as a tree + // (Root at the left edge → Dummy children visually nested under + // it). The 20-px gutter on the left of each block holds an + // L-shaped connector that explicitly anchors the dummy back to + // Root, so the parent/child relationship is unmistakable even + // when the helpBox borders blend in with the inspector. + foreach (var dummy in hierarchyDummies) { - if (colGo == null) continue; - var mf = colGo.GetComponent(); - Mesh mesh = mf != null ? mf.sharedMesh : null; - int cverts = mesh != null ? mesh.vertexCount : 0; - string suffix = $"COL {cverts:N0}v"; - DrawEditableName(colGo, suffix, 1); - } + if (dummy == null || dummy.dummy == null) continue; - // Apply / Normalize buttons - EditorGUILayout.Space(4); - EditorGUILayout.BeginHorizontal(); + EditorGUILayout.BeginHorizontal(); + var gutter = GUILayoutUtility.GetRect(HierarchyDummyIndent, 22, + GUILayout.Width(HierarchyDummyIndent), GUILayout.Height(22)); + if (Event.current.type == EventType.Repaint) + { + var line = new Color(0.55f, 0.55f, 0.55f); + float xMid = gutter.x + HierarchyDummyIndent * 0.5f; + // Vertical drop from top of gutter to the elbow. + EditorGUI.DrawRect(new Rect(xMid, gutter.y, 1, 14), line); + // Horizontal stub from the elbow into the helpBox. + EditorGUI.DrawRect(new Rect(xMid, gutter.y + 14, + HierarchyDummyIndent * 0.5f + 2, 1), line); + } - bool hasPending = pendingNames != null && pendingNames.Count > 0; - var bgc = GUI.backgroundColor; + EditorGUILayout.BeginVertical(); + DrawDummyBlock(dummy); + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); - GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f); - GUI.enabled = hasPending; - if (GUILayout.Button($"Apply Names ({(hasPending ? pendingNames.Count : 0)})", GUILayout.Height(22))) - ApplyPendingNames(); - GUI.enabled = true; + if (hierarchyDummies == null) break; + } - GUI.backgroundColor = new Color(0.7f, 0.4f, 0.95f); - if (GUILayout.Button("Normalize", GUILayout.Height(22))) - NormalizeHierarchy(); + DrawPendingAddDummyPlaceholders(); + DrawAddDummyButton(); + } - GUI.backgroundColor = bgc; - EditorGUILayout.EndHorizontal(); + // Render a placeholder block for each queued Add-Dummy entry so the + // user can SEE what they've stacked before clicking Apply Changes. + // Click ✕ on a placeholder to remove it from the queue. + void DrawPendingAddDummyPlaceholders() + { + if (pendingAddDummies == null || pendingAddDummies.Count == 0) return; + for (int i = 0; i < pendingAddDummies.Count; i++) + { + var pending = pendingAddDummies[i]; + if (pending == null) continue; - // Warnings - if (MeshHygieneUtility.HasLodOrColSuffix(root.name)) - EditorGUILayout.HelpBox("Root name has LOD/COL suffix.", MessageType.Warning); + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyDummyIndent); - var rootMf = root.GetComponent(); - if (rootMf != null && rootMf.sharedMesh != null) - EditorGUILayout.HelpBox("Root has mesh — should be empty pivot.", MessageType.Warning); + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.80f, 0.55f, 1.0f); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.backgroundColor = prevBg; - // Scale warnings - if (root.localScale != Vector3.one) - EditorGUILayout.HelpBox( - $"Root scale: {root.localScale} — should be (1,1,1). Click Normalize to fix.", - MessageType.Warning); - foreach (Transform child in root) - { - if (child.localScale != Vector3.one) + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"⋯ PENDING DUMMY {pending.name}", + EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + GUI.backgroundColor = new Color(0.90f, 0.30f, 0.30f); + if (GUILayout.Button(new GUIContent("✕", + "Discard this pending Dummy creation."), + EditorStyles.miniButton, + GUILayout.Width(22), GUILayout.Height(16))) { - EditorGUILayout.HelpBox( - $"{child.name}: scale {child.localScale} — not normalized.", - MessageType.Warning); - break; // show only first to avoid spam + pendingAddDummies.RemoveAt(i); + GUI.backgroundColor = prevBg; + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); + requestRepaint?.Invoke(); + return; } + GUI.backgroundColor = prevBg; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.LabelField( + "Empty container will be created on Apply Changes.", + EditorStyles.miniLabel); + + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); } } - static int GetLodIndexFromName(string name) + // Queue an empty Dummy GameObject creation. The actual GameObject + // lands on Apply Changes — until then nothing in the scene + // changes; the queued count reads on the button label so the user + // can see how many they've stacked. + void DrawAddDummyButton() { - var match = System.Text.RegularExpressions.Regex.Match( - name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - return match.Success ? int.Parse(match.Groups[1].Value) : -1; + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyDummyIndent); + var bgc = GUI.backgroundColor; + int pendingCount = pendingAddDummies?.Count ?? 0; + GUI.backgroundColor = pendingCount > 0 + ? new Color(0.95f, 0.65f, 0.20f) // amber — has queued + : new Color(0.55f, 0.85f, 0.55f); // green — idle + string label = pendingCount > 0 + ? $"+ Add Dummy group ({pendingCount} queued)" + : "+ Add Dummy group"; + if (GUILayout.Button( + new GUIContent(label, + "Queue a new empty container under Root. Click multiple times to queue more. " + + "Created on Apply Changes; nothing in the scene changes until you click that. " + + "After commit, drop meshes into the dummy via Unity's Inspector or click " + + "+ Add LOD inside the new block."), + GUILayout.Height(20))) + EnqueueAddEmptyDummy(); + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); } - void DrawEditableName(GameObject go, string suffix, int indent) - { - if (go == null) return; - int id = go.GetInstanceID(); - - // Get current editing value or actual name - if (!pendingNames.TryGetValue(id, out string editName)) - editName = go.name; + // ── Pending wrap / add Dummy queue helpers ── + // Both operations are deferred until Apply Changes — clicking the + // chain "→ Dummy" or the "+ Add Dummy group" button only enqueues + // the action; the scene stays untouched until commit. - EditorGUILayout.BeginHorizontal(); + bool IsChainWrapPending(HierarchyChain chain) + { + if (pendingWrapChains == null || chain == null) return false; + foreach (var p in pendingWrapChains) + if (p != null && string.Equals(p.baseName, chain.baseName, System.StringComparison.Ordinal)) + return true; + return false; + } - // Tree indent with visual connector - if (indent > 0) + void ToggleChainWrapPending(HierarchyChain chain) + { + if (pendingWrapChains == null) pendingWrapChains = new List(); + for (int i = 0; i < pendingWrapChains.Count; i++) { - GUILayout.Space(indent * 16); - var lineRect = EditorGUILayout.GetControlRect(false, 18, GUILayout.Width(12)); - if (Event.current.type == EventType.Repaint) + var p = pendingWrapChains[i]; + if (p != null && string.Equals(p.baseName, chain.baseName, System.StringComparison.Ordinal)) { - var c = new Color(0.5f, 0.5f, 0.5f, 0.5f); - // Vertical line - EditorGUI.DrawRect(new Rect(lineRect.x, lineRect.y, 1, lineRect.height), c); - // Horizontal connector - EditorGUI.DrawRect(new Rect(lineRect.x, lineRect.y + lineRect.height * 0.5f, 10, 1), c); + pendingWrapChains.RemoveAt(i); + requestRepaint?.Invoke(); + return; } } - - // Editable name field - string newName = EditorGUILayout.TextField(editName, GUILayout.MinWidth(80)); - if (newName != editName) + // Capture renderer references at queue time so the commit step + // can reparent them even after the dummy view is rebuilt. + var renderers = new List(); + foreach (var lod in chain.rows) + if (lod?.renderer != null) renderers.Add(lod.renderer); + pendingWrapChains.Add(new PendingWrapChain { - if (newName != go.name) - pendingNames[id] = newName; - else - pendingNames.Remove(id); - } - - // Suffix label (LOD0, COL, etc.) - EditorGUILayout.LabelField(suffix, EditorStyles.miniLabel, GUILayout.Width(150)); + baseName = chain.baseName, + renderers = renderers, + }); + requestRepaint?.Invoke(); + } - // Changed indicator - if (pendingNames.ContainsKey(id)) - { - var r = EditorGUILayout.GetControlRect(false, 14, GUILayout.Width(14)); - EditorGUI.DrawRect(new Rect(r.x + 2, r.y + 2, 10, 10), new Color(1f, 0.7f, 0.2f)); + void EnqueueAddEmptyDummy() + { + if (pendingAddDummies == null) pendingAddDummies = new List(); + // Pick a fresh placeholder name that won't collide with existing + // children OR with already-queued pending adds. The user can + // rename it via Apply Changes after the GameObject lands. + var root = ctx?.LodGroup != null ? ctx.LodGroup.transform : null; + int idx = 1; + string name; + while (true) + { + name = $"Dummy_{idx}"; + bool collides = false; + if (root != null && root.Find(name) != null) collides = true; + else + { + foreach (var p in pendingAddDummies) + if (p != null && string.Equals(p.name, name, System.StringComparison.Ordinal)) + { collides = true; break; } + } + if (!collides) break; + idx++; } - - EditorGUILayout.EndHorizontal(); + pendingAddDummies.Add(new PendingAddDummy { name = name }); + requestRepaint?.Invoke(); } - void ApplyPendingNames() + void DrawRootRow() { - if (pendingNames == null || pendingNames.Count == 0) return; - - Undo.SetCurrentGroupName("Prefab Builder: Rename"); - int group = Undo.GetCurrentGroup(); - - foreach (var kvp in pendingNames) - { - var go = EditorUtility.InstanceIDToObject(kvp.Key) as GameObject; - if (go == null || go.name == kvp.Value) continue; + var rootGo = ctx.LodGroup.gameObject; - Undo.RecordObject(go, "Rename"); - UvtLog.Info($"Renamed: {go.name} -> {kvp.Value}"); - go.name = kvp.Value; + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Root", EditorStyles.miniLabel, GUILayout.Width(34)); + DrawEditableNameField(rootGo, GUILayout.MinWidth(120)); + GUILayout.FlexibleSpace(); + + // Apply Changes is the single commit point: pending name edits, + // pending LOD inserts, pending LOD deletes, and leaf-name renumber + // are all applied together. Disabled when nothing is queued so it's + // visually obvious the prefab is already in sync. + int pendingTotal = + (pendingNames?.Count ?? 0) + + (pendingInserts?.Count ?? 0) + + (pendingDeleteRendererIds?.Count ?? 0) + + (pendingWrapChains?.Count ?? 0) + + (pendingAddDummies?.Count ?? 0); + bool hasAny = pendingTotal > 0 || HasAnyStaleLeafName(); + var bgc = GUI.backgroundColor; + GUI.backgroundColor = hasAny + ? new Color(0.40f, 0.80f, 0.40f) + : new Color(0.55f, 0.55f, 0.55f); + string label = hasAny ? $"Apply Changes ({pendingTotal})" : "Apply Changes"; + var tooltip = new GUIContent(label, + "Commit every pending change at once:\n" + + " • Root / Dummy name edits\n" + + " • Pending LOD inserts (creates GameObject + simplifies mesh)\n" + + " • Pending LOD deletes\n" + + " • Renumber trailing _LOD{N} / _COL suffixes to match slots\n\n" + + "Until you click this, the prefab itself is untouched."); + using (new EditorGUI.DisabledScope(!hasAny)) + { + if (GUILayout.Button(tooltip, GUILayout.Height(20), GUILayout.Width(160))) + ApplyChanges(); } + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); - Undo.CollapseUndoOperations(group); - pendingNames.Clear(); - - buildIntent |= FbxExportIntent.Hierarchy; - - // Refresh context since names changed - if (ctx.LodGroup != null) ctx.Refresh(ctx.LodGroup); - requestRepaint?.Invoke(); + // Sanity warnings on root + if (MeshHygieneUtility.HasLodOrColSuffix(rootGo.name)) + EditorGUILayout.HelpBox("Root name has LOD/COL suffix.", MessageType.Warning); + var rootMf = rootGo.GetComponent(); + if (rootMf != null && rootMf.sharedMesh != null) + EditorGUILayout.HelpBox("Root has mesh — should be empty pivot.", MessageType.Warning); } - void NormalizeHierarchy() + void DrawDummyBlock(HierarchyDummy dummy) { - if (ctx.LodGroup == null) return; + if (dummy == null || dummy.dummy == null) return; - Undo.SetCurrentGroupName("Prefab Builder: Normalize"); - int group = Undo.GetCurrentGroup(); + EditorGUILayout.Space(4); - var root = ctx.LodGroup.transform; - string baseName = UvToolContext.ExtractGroupKey(root.name); - if (string.IsNullOrEmpty(baseName)) baseName = root.name; + // Compute chains up front so the dummy header summary can include + // chain-wide badges in the single-chain case (where no chain + // foldout header is rendered to host them). + var chains = GroupLodsByChain(dummy); + bool useChainFoldouts = chains.Count > 1; + + // Tint the helpBox per Dummy via GUI.backgroundColor — green for the + // implicit root group, blue for explicit Dummy containers. Visually + // separates multiple Dummy groups in a busy hierarchy. + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = dummy.isRoot + ? new Color(0.75f, 1.05f, 0.75f) + : new Color(0.78f, 0.92f, 1.10f); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.backgroundColor = prevBg; + + // Header: small foldout arrow + dummy name (editable when not root) + summary. + EditorGUILayout.BeginHorizontal(); + var foldoutRect = GUILayoutUtility.GetRect(14, 16, GUILayout.Width(14), GUILayout.Height(16)); + dummy.foldout = EditorGUI.Foldout(foldoutRect, dummy.foldout, GUIContent.none, true); + if (dummy.isRoot) + EditorGUILayout.LabelField("Root group", EditorStyles.boldLabel, GUILayout.MinWidth(100)); + else + DrawEditableNameField(dummy.dummy.gameObject, GUILayout.MinWidth(100)); + GUILayout.FlexibleSpace(); + string dummySummary = $"{dummy.lods.Count} LOD · {dummy.cols.Count} COL"; + // Single-chain dummies don't render a chain foldout header, so + // the channel badges have to live on the dummy header instead. + // Multi-chain dummies put their badges on each chain header + // (different chains can have different channel sets). + if (!useChainFoldouts && chains.Count == 1 && chains[0].rows.Count > 0) + { + string b = ChannelBadges(chains[0].rows[0].mesh); + if (!string.IsNullOrEmpty(b)) + dummySummary += " · " + b; + } + EditorGUILayout.LabelField(dummySummary, + EditorStyles.miniLabel, GUILayout.Width(170)); + EditorGUILayout.EndHorizontal(); - // Sanitize root name - if (MeshHygieneUtility.HasInvalidChars(root.name)) + if (!dummy.foldout) { - string sanitized = MeshHygieneUtility.SanitizeName(root.name); - Undo.RecordObject(root.gameObject, "Sanitize Root"); - root.name = sanitized; - baseName = UvToolContext.ExtractGroupKey(sanitized); + EditorGUILayout.EndVertical(); + return; } - // Strip LOD/COL suffix from root - if (MeshHygieneUtility.HasLodOrColSuffix(root.name)) + EditorGUILayout.Space(2); + + // Render any pending inserts that should appear BEFORE the first + // existing LOD (afterRenderer == null). + if (DrawPendingInsertsForAfter(dummy, null)) { - Undo.RecordObject(root.gameObject, "Strip Root Suffix"); - root.name = baseName; + EditorGUILayout.EndVertical(); + return; } - // Move root mesh to child if root has mesh - var rootMf = root.GetComponent(); - if (rootMf != null && rootMf.sharedMesh != null) - { - var rootMr = root.GetComponent(); - var lod0Child = new GameObject(baseName + "_LOD0"); - Undo.RegisterCreatedObjectUndo(lod0Child, "Move Root Mesh"); - lod0Child.transform.SetParent(root, false); - - var newMf = lod0Child.AddComponent(); - newMf.sharedMesh = rootMf.sharedMesh; - if (rootMr != null) - { - var newMr = lod0Child.AddComponent(); - newMr.sharedMaterials = rootMr.sharedMaterials; - newMr.shadowCastingMode = rootMr.shadowCastingMode; - newMr.receiveShadows = rootMr.receiveShadows; - GameObjectUtility.SetStaticEditorFlags(lod0Child, - GameObjectUtility.GetStaticEditorFlags(root.gameObject)); - Undo.DestroyObjectImmediate(rootMr); + // LOD rows grouped by chain base name with a Unity Hierarchy- + // style foldout per chain when there's more than one chain. + // Foldout chevron sits at the dummy's content edge; chain rows + // are indented one level deeper (14 px) to mirror Unity's + // child-of-parent visual nesting. Single-chain dummies skip + // the chain foldout entirely so the rows sit at the dummy + // indent without an extra step. + // + // Insert buttons stay slot-scoped: clicking "+ Add LOD after + // LOD{N}" in any chain queues a single PendingInsert that + // materialises a new slot across all chains on Apply Changes. + for (int chainIdx = 0; chainIdx < chains.Count; chainIdx++) + { + var chain = chains[chainIdx]; + bool chainOpen = true; + Color prevChainBg = GUI.backgroundColor; + if (useChainFoldouts) + { + if (chainIdx > 0) EditorGUILayout.Space(2); + // Tint the chain container with the per-group palette + // entry — this is the visual identification the user + // asked for. helpBox texture multiplies with + // GUI.backgroundColor so a saturated chain colour reads + // as a soft pastel background covering the entire chain + // block (header + rows + insert buttons). + GUI.backgroundColor = MeshGroupColors.GetColor(chain.baseName); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.backgroundColor = prevChainBg; + chainOpen = DrawChainFoldoutHeader(dummy, chain); + } + + if (!chainOpen) + { + if (useChainFoldouts) EditorGUILayout.EndVertical(); + continue; + } + + bool earlyReturn = false; + if (useChainFoldouts) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(14f); + EditorGUILayout.BeginVertical(); + } + for (int i = 0; i < chain.rows.Count; i++) + { + if (i > 0) DrawRowDivider(); + if (DrawLodRow(dummy, chain.rows[i])) { earlyReturn = true; break; } + if (DrawPendingInsertsForAfter(dummy, chain.rows[i].renderer)) + { earlyReturn = true; break; } + } + // Single insert button at the bottom of the chain. Replaces + // the inter-row "+" affordance which broke the visual + // rhythm — three rows per LOD plus a "+" gap-row between + // every pair turned the hierarchy into a noisy zig-zag. + // Inserts after the last existing LOD; mid-chain insert is + // out-of-scope for this view (rare operation). + if (!earlyReturn && chain.rows.Count > 0) + { + int lastLodIndex = chain.rows[chain.rows.Count - 1].lodIndex; + if (DrawAddLodButton(dummy, lastLodIndex)) + earlyReturn = true; + } + if (useChainFoldouts) + { + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + if (earlyReturn) + { + EditorGUILayout.EndVertical(); + return; } - Undo.DestroyObjectImmediate(rootMf); } - // Normalize LOD child names. - // Strategy: group by polycount to detect LOD tiers, then ensure each - // child has a valid _LOD{N} suffix. Preserve existing descriptive names - // (e.g. material names from split) — only fix the LOD suffix. - var colSet = new HashSet(MeshHygieneUtility.FindCollisionObjects(root)); - var meshChildren = new List<(Transform t, int polyCount)>(); - foreach (Transform child in root) + // No LODs yet — single Add at top + if (dummy.lods.Count == 0) { - if (colSet.Contains(child.gameObject)) continue; - var mf = child.GetComponent(); - if (mf == null || mf.sharedMesh == null) continue; - meshChildren.Add((child, MeshHygieneUtility.GetTriangleCount(mf.sharedMesh))); + if (DrawAddLodButton(dummy, -1)) + { + EditorGUILayout.EndVertical(); + return; + } } - if (meshChildren.Count > 0) + // COL rows live in their own tinted helpBox so they sit at the + // same indent / right-pad as the chain blocks above. Wrapping + // the section gives a visible border (separator from the last + // LOD's "+" insert button) plus consistent left/right padding + // — without the box, COL rows spread to the dummy helpBox + // edges and the ✕ button ends up ~20 px right of the LOD ✕ + // cluster. + if (dummy.cols.Count > 0) { - // Group into LOD tiers by polycount (descending). - // Children with same or similar polycount are in the same LOD tier. - meshChildren.Sort((a, b) => b.polyCount.CompareTo(a.polyCount)); + EditorGUILayout.Space(4); + var prevColBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.65f, 0.95f, 0.75f); // soft mint + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.backgroundColor = prevColBg; - // If children already have valid _LOD{N} suffixes, just ensure suffix is correct - bool hasExistingLodNames = false; - foreach (var (t, _) in meshChildren) + bool colEarly = false; + for (int i = 0; i < dummy.cols.Count; i++) { - if (System.Text.RegularExpressions.Regex.IsMatch( - t.name, @"_LOD\d+$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + if (i > 0) DrawRowDivider(); + if (DrawColRow(dummy, dummy.cols[i], i, dummy.cols.Count)) { - hasExistingLodNames = true; + colEarly = true; break; } } - if (hasExistingLodNames) + EditorGUILayout.EndVertical(); + if (colEarly) { - // Multi-mesh LODs: preserve names, only fix missing LOD suffix. - // Don't renumber — existing suffixes reflect the intended LOD level. - foreach (var (t, _) in meshChildren) - { - if (!System.Text.RegularExpressions.Regex.IsMatch( - t.name, @"_LOD\d+$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - // No LOD suffix — add _LOD0 as default - Undo.RecordObject(t.gameObject, "Add LOD Suffix"); - t.name = t.name + "_LOD0"; - } - } + EditorGUILayout.EndVertical(); // dummy helpBox + return; } - else + } + + // Hint when any child's _LOD/_COL suffix doesn't match its slot. + if (DummyHasStale(dummy)) + { + EditorGUILayout.Space(2); + EditorGUILayout.HelpBox( + "Trailing _LOD/_COL suffix doesn't match the slot. Apply Changes will renumber.", + MessageType.None); + } + + EditorGUILayout.EndVertical(); + } + + const float HierarchyRowIndent = 10f; + const float HierarchyDummyIndent = 18f; + // Trailing right-margin for rows nested inside a chain helpBox. + // The helpBox border + the left sidebar's vertical scrollbar both + // eat width on the right; without an explicit pad sliders / "+" + // buttons / etc. spill past the visible rect. + const float ChainContentRightPad = 18f; + + // Returns true when the operation invalidates the UI for this frame. + bool DrawLodRow(HierarchyDummy dummy, HierarchyLodRow lod) + { + if (lod == null || lod.renderer == null) return false; + + int rid = lod.renderer.GetInstanceID(); + bool markedDelete = pendingDeleteRendererIds != null && pendingDeleteRendererIds.Contains(rid); + bool fresh = !markedDelete && freshRendererIds != null && freshRendererIds.Contains(rid); + bool stale = !markedDelete && !fresh && IsLodRowStale(dummy, lod); + bool regenerated = fresh && RowWasRegenerated(lod); + + int verts = lod.mesh != null ? lod.mesh.vertexCount : 0; + int tris = lod.mesh != null ? MeshHygieneUtility.GetTriangleCount(lod.mesh) : 0; + string stat = $"{verts:N0}v / {tris:N0}t"; + + // Row A: marker + name + stats + actions. Stats moved inline so + // we drop the dedicated mini-stats row that previously sat + // between name and slider — the per-LOD entry collapses from 3 + // rows to 2. Channel badges (UV0·UV1·N·T …) are now shown once + // on the chain header instead of repeated on every LOD row, + // since channel layout is consistent across a chain. + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyRowIndent); + DrawStatusMarker(fresh, stale, markedDelete); + + string shortName = ShortLeafName(lod.renderer.gameObject.name); + string nameLabel; + if (markedDelete) + nameLabel = "✗ DELETE " + shortName; + else if (fresh) + nameLabel = "★ NEW " + shortName; + else if (stale) + nameLabel = "⚠ " + shortName; + else + nameLabel = shortName; + + var prevBg = GUI.backgroundColor; + if (markedDelete) + GUI.backgroundColor = new Color(1.0f, 0.35f, 0.35f); // red — pending delete + else if (fresh) + GUI.backgroundColor = new Color(1.0f, 0.55f, 0.10f); // bright orange — just inserted/regen + else if (stale) + GUI.backgroundColor = new Color(0.95f, 0.78f, 0.30f); // amber — name out of sync + // Click on the name pings the renderer's GameObject in the + // Hierarchy window so the user can locate it quickly. Name field + // expands to absorb whatever width is left after stats + buttons. + if (GUILayout.Button(nameLabel, + EditorStyles.textField, + GUILayout.MinWidth(70), GUILayout.ExpandWidth(true))) + { + EditorGUIUtility.PingObject(lod.renderer.gameObject); + } + GUI.backgroundColor = prevBg; + + EditorGUILayout.LabelField(stat, EditorStyles.miniLabel, + GUILayout.Width(78)); + + // Discard reverts a regenerate-in-place back to the import-time + // fbxMesh. Only shown for rows whose mesh actually differs from + // the FBX source (i.e. ones we know we modified this session). + if (regenerated) + { + GUI.backgroundColor = new Color(0.85f, 0.65f, 0.30f); + if (GUILayout.Button(new GUIContent("↶", + "Discard regenerate — restore the import-time FBX mesh on this LOD."), + GUILayout.Width(22), GUILayout.Height(18))) { - // Simple case: no existing LOD names. Single mesh per LOD tier. - // Rename sequentially: baseName_LOD0, baseName_LOD1, etc. - for (int i = 0; i < meshChildren.Count; i++) - { - string newName = baseName + "_LOD" + i; - if (meshChildren[i].t.name != newName) - { - Undo.RecordObject(meshChildren[i].t.gameObject, "Rename LOD"); - meshChildren[i].t.name = newName; - } - } + DiscardRegenerate(lod); + GUI.backgroundColor = prevBg; + EditorGUILayout.EndHorizontal(); + return true; } } - // Sanitize child names - foreach (Transform child in root) + GUI.backgroundColor = new Color(0.60f, 0.75f, 0.90f); + using (new EditorGUI.DisabledScope(markedDelete)) { - if (MeshHygieneUtility.HasInvalidChars(child.name)) + if (GUILayout.Button(new GUIContent("↻", + "Regenerate this LOD from LOD0 source with the current quality."), + GUILayout.Width(22), GUILayout.Height(18))) { - string sanitized = MeshHygieneUtility.SanitizeName(child.name); - Undo.RecordObject(child.gameObject, "Sanitize Name"); - child.name = sanitized; + RegenerateLodWithQuality(dummy, lod); + GUI.backgroundColor = prevBg; + EditorGUILayout.EndHorizontal(); + return true; } } - // Normalize scale: bake non-identity transforms into mesh vertices. - // Common issue: FBX imported at scale 0.01 with 100x compensating - // scale on nodes, or vice versa. Bake into verts → set scale to 1,1,1. - NormalizeChildScales(root); - - // Group multi-mesh LODs into container nodes by mesh group key. - // e.g. TubBig2_m_WoodenBoxes_LOD0 + _LOD1 → container "TubBig2_m_WoodenBoxes" - GroupMeshChildrenByMaterial(root); - - // Rebuild LODGroup from hierarchy naming - RebuildLodGroupFromNames(); - - Undo.CollapseUndoOperations(group); - pendingNames?.Clear(); + // ✕ toggles a pending-delete mark instead of destroying the row + // immediately. Apply Changes commits all marked rows together. + GUI.backgroundColor = markedDelete + ? new Color(0.55f, 0.85f, 0.55f) + : new Color(0.90f, 0.30f, 0.30f); + string xTooltip = markedDelete + ? "Cancel pending delete (revert mark)." + : "Mark this LOD for deletion. Applied on Apply Changes."; + if (GUILayout.Button(new GUIContent(markedDelete ? "↶" : "✕", xTooltip), + GUILayout.Width(22), GUILayout.Height(18))) + { + if (markedDelete) pendingDeleteRendererIds.Remove(rid); + else pendingDeleteRendererIds.Add(rid); + requestRepaint?.Invoke(); + GUI.backgroundColor = prevBg; + EditorGUILayout.EndHorizontal(); + return true; + } + GUI.backgroundColor = prevBg; + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - // NormalizeChildScales bakes node transforms into vertex - // positions, which moves verts (and re-derived normals) — both - // require wide intent. GroupMeshChildrenByMaterial reshuffles - // the hierarchy; RebuildLodGroupFromNames rebuilds the - // LODGroup component. - buildIntent |= FbxExportIntent.Hierarchy - | FbxExportIntent.LodGroup - | FbxExportIntent.Normals - | FbxExportIntent.Tangents; + // Row B: slider + inline value field. EditorGUILayout.Slider + // draws both in a single coherent widget — switching to a + // manual HorizontalSlider + DelayedFloatField pair caused the + // value field to wrap to a new line in narrow sidebars (the + // slider's ExpandWidth was greedy and didn't leave room for + // the field). fieldWidth pins the value field to a + // predictable 56 px so the right edge still aligns with the + // [↻][✕] button cluster on row A. + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyRowIndent); + if (!lodQualitySliders.TryGetValue(rid, out var quality)) + quality = ComputeLodRatioFromTriangles(dummy, lod); + float prevFieldW = EditorGUIUtility.fieldWidth; + EditorGUIUtility.fieldWidth = 56f; + float newQuality = EditorGUILayout.Slider(quality, 0.001f, 1f); + EditorGUIUtility.fieldWidth = prevFieldW; + if (Mathf.Abs(newQuality - quality) > 0.0001f) + lodQualitySliders[rid] = newQuality; + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - ctx.Refresh(ctx.LodGroup); - requestRepaint?.Invoke(); - UvtLog.Info("Normalized hierarchy."); + return false; } - /// - /// Group direct mesh children into container nodes by mesh group key. - /// When multiple meshes share the same base name (e.g. TubBig2_m_WoodenBoxes_LOD0, - /// TubBig2_m_WoodenBoxes_LOD1), they are moved under a container node named - /// with the group key (TubBig2_m_WoodenBoxes). Single-mesh groups stay flat. - /// Children already inside containers are skipped. - /// - void GroupMeshChildrenByMaterial(Transform root) + // Compact insert affordance between LOD rows. A single small "+" + // mini-button centred via FlexibleSpace so it can never overflow + // past the chain / dummy helpBox border (the previous design used + // ExpandWidth rules on either side, which IMGUI happily extended + // past the available rect — clipping the button on narrow + // sidebars). Click enqueues a slot-scoped pending insert. + // Returns true on click (UI invalidated by insertion). + bool DrawAddLodButton(HierarchyDummy dummy, int afterLodIndex) { - var colSet = new HashSet(MeshHygieneUtility.FindCollisionObjects(root)); + const float btnW = 36f; + const float btnH = 14f; - // Collect direct mesh children (not collision, not containers) - var directMeshChildren = new List(); - foreach (Transform child in root) - { - if (colSet.Contains(child.gameObject)) continue; - var mf = child.GetComponent(); - if (mf != null && mf.sharedMesh != null) - directMeshChildren.Add(child); - } + string tip = afterLodIndex < 0 + ? "Click to queue an insert at the start of this group. Commits on Apply Changes." + : $"Click to queue an insert after LOD{afterLodIndex}. Commits on Apply Changes."; - // Group by mesh group key (name without LOD suffix) - var groups = new Dictionary>(); - foreach (var child in directMeshChildren) - { - string groupKey = UvToolContext.ExtractGroupKey(child.name); - if (string.IsNullOrEmpty(groupKey)) groupKey = child.name; - if (!groups.ContainsKey(groupKey)) - groups[groupKey] = new List(); - groups[groupKey].Add(child); - } + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyRowIndent); + GUILayout.FlexibleSpace(); + + var bgc = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.55f, 0.75f, 0.95f); + bool clicked = GUILayout.Button(new GUIContent("+", tip), + EditorStyles.miniButton, + GUILayout.Width(btnW), GUILayout.Height(btnH)); + GUI.backgroundColor = bgc; - // Only create containers for groups with multiple LOD variants - // AND where the group key differs from the root base name - // (if all children share root's base name, keep flat) - string rootBase = UvToolContext.ExtractGroupKey(root.name); - int containerCount = 0; + GUILayout.FlexibleSpace(); + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - foreach (var kvp in groups) + if (clicked) { - if (kvp.Value.Count <= 1) continue; - if (kvp.Key == rootBase && groups.Count == 1) continue; // single group = keep flat - - // Check if container already exists - Transform existing = null; - foreach (Transform child in root) - { - if (child.name == kvp.Key && child.GetComponent() == null) - { existing = child; break; } - } + EnqueuePendingInsert(afterLodIndex); + return true; + } + return false; + } - Transform container; - if (existing != null) - { - container = existing; - } - else + // ── Pending insert row ── + // Render every pending insert whose afterLodIndex matches the slot + // currently occupied by the given anchor renderer. Pending entries + // are slot-scoped, so the row is shown in EVERY Dummy block at the + // same slot — that's the visible counterpart of the synced commit + // (every dummy gets a renderer at the new slot on Apply Changes). + // Returns true when a row mutated state (UI invalidated). + bool DrawPendingInsertsForAfter(HierarchyDummy dummy, Renderer afterRenderer) + { + if (pendingInserts == null || pendingInserts.Count == 0) return false; + int anchorSlot = -1; + if (afterRenderer != null) + { + // Anchor is one of THIS dummy's renderers — its lodIndex was + // already cached on the matching HierarchyLodRow, but find it + // explicitly here so the helper is self-contained. + foreach (var lr in dummy.lods) { - var containerGo = new GameObject(kvp.Key); - Undo.RegisterCreatedObjectUndo(containerGo, "Group LODs"); - containerGo.transform.SetParent(root, false); - container = containerGo.transform; - containerCount++; + if (lr == null || lr.renderer != afterRenderer) continue; + anchorSlot = lr.lodIndex; + break; } - - // Move children into container - foreach (var child in kvp.Value) + if (anchorSlot < 0) return false; + } + for (int i = 0; i < pendingInserts.Count; i++) + { + var p = pendingInserts[i]; + if (p == null) continue; + if (p.afterLodIndex != anchorSlot) continue; + DrawRowDivider(); + if (DrawPendingInsertRow(p)) { - if (child.parent == container) continue; - Undo.SetTransformParent(child, container, "Group LODs"); - child.localPosition = Vector3.zero; - child.localRotation = Quaternion.identity; - child.localScale = Vector3.one; + pendingInserts.RemoveAt(i); + requestRepaint?.Invoke(); + return true; } } - - if (containerCount > 0) - UvtLog.Info($"Created {containerCount} mesh group container(s)."); + return false; } - /// - /// Bake non-identity transforms into mesh vertices for root and all children. - /// Handles the common case of FBX at scale 0.01 with 100x node compensation. - /// After this, all transforms are identity and vertex positions are in world-correct space. - /// - void NormalizeChildScales(Transform root) + // Returns true on cancel (UI invalidated). + bool DrawPendingInsertRow(PendingInsert pending) { - bool rootHasScale = root.localScale != Vector3.one; - Matrix4x4 rootScaleMatrix = rootHasScale - ? Matrix4x4.Scale(root.localScale) - : Matrix4x4.identity; + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyRowIndent); + + // Marker — bright violet so pending rows are unmistakable. + const float markerW = 6f; + const float markerH = 18f; + var rect = GUILayoutUtility.GetRect(markerW, markerH, + GUILayout.Width(markerW), GUILayout.Height(markerH)); + if (Event.current.type == EventType.Repaint) + EditorGUI.DrawRect(new Rect(rect.x, rect.y + 2, markerW - 1, markerH - 4), + new Color(0.65f, 0.30f, 0.95f)); + + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.80f, 0.55f, 1.0f); + EditorGUILayout.LabelField($"⋯ PENDING LOD (insert on Apply Changes)", + EditorStyles.textField, GUILayout.MinWidth(120)); + GUI.backgroundColor = prevBg; + + GUILayout.FlexibleSpace(); + GUI.backgroundColor = new Color(0.90f, 0.30f, 0.30f); + bool cancelled = GUILayout.Button( + new GUIContent("✕", "Discard this pending LOD insert."), + GUILayout.Width(22), GUILayout.Height(18)); + GUI.backgroundColor = prevBg; + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - // Process each direct child: bake rootScale * childLocal into mesh, reset to identity - foreach (Transform child in root) - { - var mf = child.GetComponent(); - if (mf == null || mf.sharedMesh == null) continue; + if (cancelled) return true; - bool childHasTransform = child.localPosition != Vector3.zero || - child.localRotation != Quaternion.identity || - child.localScale != Vector3.one; + // Quality slider — live editable so the user can preview the + // chosen ratio in the row before committing. Single-widget + // layout (matches the existing-LOD slider row) so the value + // field stays inline with the slider track instead of wrapping + // to a second line on narrow sidebars. + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyRowIndent); + float prevFieldW = EditorGUIUtility.fieldWidth; + EditorGUIUtility.fieldWidth = 56f; + pending.quality = Mathf.Clamp(EditorGUILayout.Slider( + Mathf.Clamp(pending.quality, 0.001f, 1f), 0.001f, 1f), + 0.001f, 1f); + EditorGUIUtility.fieldWidth = prevFieldW; + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - if (!rootHasScale && !childHasTransform) - continue; + return false; + } - if (!mf.sharedMesh.isReadable) + // Enqueue a pending insert for this dummy. Captures the renderer that + // it should follow so the slot index can be recomputed accurately at + // Apply time even after structural shifts. Default quality = half of + // the average previous LOD slider across all dummies that share this + // slot (so multi-dummy prefabs get a sensible starting ratio without + // privileging the dummy where the click happened). + void EnqueuePendingInsert(int afterLodIndex) + { + if (pendingInserts == null) pendingInserts = new List(); + float defaultQ = 0.5f; + // Pick any renderer at the anchor slot as the live anchor. The + // commit step resolves the actual slot index from this renderer's + // CURRENT position in the LODGroup, so deletes earlier in the + // same Apply batch shift the insert into the right slot. + Renderer afterRenderer = null; + if (afterLodIndex >= 0 && hierarchyDummies != null && lodQualitySliders != null) + { + int samples = 0; + float sum = 0f; + foreach (var dummy in hierarchyDummies) { - UvtLog.Warn($"Cannot normalize transform on '{child.name}' — mesh not readable."); - continue; + if (dummy?.lods == null) continue; + foreach (var lr in dummy.lods) + { + if (lr == null || lr.renderer == null) continue; + if (lr.lodIndex != afterLodIndex) continue; + if (afterRenderer == null) afterRenderer = lr.renderer; + if (lodQualitySliders.TryGetValue(lr.renderer.GetInstanceID(), out var q)) + { sum += q; samples++; } + } } + if (samples > 0) defaultQ = Mathf.Max(0.01f, (sum / samples) * 0.5f); + } + pendingInserts.Add(new PendingInsert + { + afterLodIndex = afterLodIndex, + afterRenderer = afterRenderer, + quality = defaultQ + }); + requestRepaint?.Invoke(); + } - // Combined matrix: root scale * child local transform - Matrix4x4 combined = rootHasScale - ? rootScaleMatrix * Matrix4x4.TRS(child.localPosition, child.localRotation, child.localScale) - : Matrix4x4.TRS(child.localPosition, child.localRotation, child.localScale); + bool DrawColRow(HierarchyDummy dummy, HierarchyColRow col, int index, int totalCols) + { + if (col == null || col.colTransform == null) return false; - BakeMatrixIntoMesh(mf, combined); + // Component-only rows (collider on the dummy itself) are never + // renamed — the host GameObject is the dummy, not a leaf. + bool stale = !col.isComponentOnly && IsColRowStale(dummy, col, index, totalCols); - if (childHasTransform) - { - Undo.RecordObject(child, "Normalize Transform"); - child.localPosition = Vector3.zero; - child.localRotation = Quaternion.identity; - child.localScale = Vector3.one; - } - - UvtLog.Info($"Normalized transform on '{child.name}' → identity"); + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(HierarchyRowIndent); + DrawStatusMarker(false, stale); + + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = stale + ? new Color(0.95f, 0.78f, 0.30f) + : new Color(0.55f, 0.95f, 0.70f); + string colShort = ShortLeafName(col.colTransform.gameObject.name); + string display = col.isComponentOnly + ? $"{col.colTransform.gameObject.name} [{col.typeLabel}]" + : colShort; + // Click pings the host GameObject so the user can jump to it. + if (GUILayout.Button(display, EditorStyles.textField, GUILayout.MinWidth(120))) + { + Object pingTarget = col.isComponentOnly && col.collider != null + ? (Object)col.collider + : col.colTransform.gameObject; + EditorGUIUtility.PingObject(pingTarget); + } + GUI.backgroundColor = prevBg; + + EditorGUILayout.LabelField(BuildColStatLabel(col), + EditorStyles.miniLabel, GUILayout.Width(180)); + + GUILayout.FlexibleSpace(); + string removeTooltip = col.isComponentOnly + ? $"Remove the {col.typeLabel}Collider component from '{col.colTransform.name}'." + : "Remove this collision GameObject."; + GUI.backgroundColor = new Color(0.90f, 0.30f, 0.30f); + if (GUILayout.Button(new GUIContent("✕", removeTooltip), + GUILayout.Width(22), GUILayout.Height(18))) + { + DeleteCol(dummy, col); + GUI.backgroundColor = prevBg; + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); + return true; } + GUI.backgroundColor = prevBg; + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - // If root had mesh (shouldn't happen after earlier normalization), bake it too - if (rootHasScale) - { - var rootMf = root.GetComponent(); - if (rootMf != null && rootMf.sharedMesh != null && rootMf.sharedMesh.isReadable) - BakeMatrixIntoMesh(rootMf, rootScaleMatrix); + return false; + } - Undo.RecordObject(root, "Normalize Root Scale"); - root.localScale = Vector3.one; - UvtLog.Info($"Normalized root scale → (1,1,1)"); - } + static string BuildColStatLabel(HierarchyColRow col) + { + if (col.collider is MeshCollider) + { + int verts = col.mesh != null ? col.mesh.vertexCount : 0; + int tris = col.mesh != null ? MeshHygieneUtility.GetTriangleCount(col.mesh) : 0; + string meshName = col.mesh != null ? col.mesh.name : "(none)"; + return $"Mesh {verts:N0}v / {tris:N0}t · {meshName}"; + } + if (col.collider is BoxCollider bc) + return $"Box {bc.size.x:F2}×{bc.size.y:F2}×{bc.size.z:F2}"; + if (col.collider is CapsuleCollider cc) + return $"Capsule r={cc.radius:F2} h={cc.height:F2}"; + if (col.collider is SphereCollider sc) + return $"Sphere r={sc.radius:F2}"; + if (col.collider != null) + return col.typeLabel ?? col.collider.GetType().Name; + int v = col.mesh != null ? col.mesh.vertexCount : 0; + int t = col.mesh != null ? MeshHygieneUtility.GetTriangleCount(col.mesh) : 0; + return $"COL {v:N0}v / {t:N0}t"; } - static void BakeMatrixIntoMesh(MeshFilter mf, Matrix4x4 matrix) + // Editable text field bound to pendingNames keyed by GameObject instanceID. + // Callers receive a draggable change indicator on the right of the input. + void DrawEditableNameField(GameObject go, params GUILayoutOption[] options) { - var mesh = mf.sharedMesh; - if (mesh == null || !mesh.isReadable) return; + if (go == null) return; + int id = go.GetInstanceID(); + if (!pendingNames.TryGetValue(id, out string editName)) + editName = go.name; - // Clone if it's an asset-backed mesh - if (!string.IsNullOrEmpty(UnityEditor.AssetDatabase.GetAssetPath(mesh))) + string newName = EditorGUILayout.TextField(editName, options); + if (newName != editName) { - mesh = Object.Instantiate(mesh); - mesh.name = mf.sharedMesh.name; - Undo.RecordObject(mf, "Bake Transform"); - mf.sharedMesh = mesh; + if (newName != go.name) pendingNames[id] = newName; + else pendingNames.Remove(id); } - var verts = mesh.vertices; - var normals = mesh.normals; - for (int i = 0; i < verts.Length; i++) + if (pendingNames.ContainsKey(id)) { - verts[i] = matrix.MultiplyPoint3x4(verts[i]); - if (normals != null && i < normals.Length) - normals[i] = matrix.MultiplyVector(normals[i]).normalized; + var dot = EditorGUILayout.GetControlRect(false, 14, GUILayout.Width(14)); + EditorGUI.DrawRect(new Rect(dot.x + 2, dot.y + 2, 10, 10), + new Color(1f, 0.7f, 0.2f)); } - mesh.SetVertices(verts); - if (normals != null && normals.Length > 0) - mesh.SetNormals(normals); - - var tangents = mesh.tangents; - if (tangents != null && tangents.Length > 0) - { - for (int i = 0; i < tangents.Length; i++) - { - Vector3 tVec = matrix.MultiplyVector( - new Vector3(tangents[i].x, tangents[i].y, tangents[i].z)).normalized; - tangents[i] = new Vector4(tVec.x, tVec.y, tVec.z, tangents[i].w); - } - mesh.tangents = tangents; - } - mesh.RecalculateBounds(); } - void RebuildLodGroupFromNames() + // ── Hierarchy view rebuild ── + + void RebuildHierarchyView() { - if (ctx.LodGroup == null) return; + hierarchyDummies = new List(); + if (ctx == null || ctx.LodGroup == null) return; var root = ctx.LodGroup.transform; - var colSet = new HashSet(MeshHygieneUtility.FindCollisionObjects(root)); - var lodChildren = new SortedDictionary>(); + var lookup = new Dictionary(); - // Search recursively — meshes can be inside group containers - foreach (var r in root.GetComponentsInChildren(true)) + // Group LOD renderers by their parent transform. A parent that equals + // root means the prefab is flat (no separate Dummy container) — in + // that case the root acts as the implicit single Dummy. + var lods = ctx.LodGroup.GetLODs(); + for (int li = 0; li < lods.Length; li++) { - if (r == null || r.transform == root) continue; - if (colSet.Contains(r.gameObject)) continue; + if (lods[li].renderers == null) continue; + foreach (var r in lods[li].renderers) + { + if (r == null) continue; + var parent = r.transform.parent; + if (parent == null) continue; + Transform key = parent == root ? root : parent; + var dummy = GetOrCreateDummy(lookup, key, root); + var mf = r.GetComponent(); + dummy.lods.Add(new HierarchyLodRow + { + lodIndex = li, + renderer = r, + mesh = mf != null ? mf.sharedMesh : null + }); + } + } - var match = System.Text.RegularExpressions.Regex.Match( - r.gameObject.name, @"_LOD(\d+)$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (!match.Success) continue; + // Two collision sources flow into each dummy block: + // 1) Collider components attached to the dummy/root GameObject itself + // (e.g. a MeshCollider on the prefab root pointing at a *_COL mesh + // asset). These are "component-only" rows. + // 2) Standalone _COL named child GameObjects (legacy convention). + // The first is the case the user just flagged — meshes referenced by a + // root-level MeshCollider were invisible in the tree. + foreach (var dummy in lookup.Values) + { + if (dummy.dummy == null) continue; + foreach (var c in dummy.dummy.GetComponents()) + { + if (c == null) continue; + Mesh m = null; + if (c is MeshCollider mc) m = mc.sharedMesh; + dummy.cols.Add(new HierarchyColRow + { + colTransform = dummy.dummy, + collider = c, + mesh = m, + typeLabel = ColliderTypeLabel(c), + isComponentOnly = true, + }); + } + } - int lodIdx = int.Parse(match.Groups[1].Value); + foreach (var colGo in MeshHygieneUtility.FindCollisionObjects(root)) + { + if (colGo == null) continue; + var colT = colGo.transform; + var parent = colT.parent; + if (parent == null) continue; + Transform key = parent == root ? root : parent; + var dummy = GetOrCreateDummy(lookup, key, root); + var mf = colGo.GetComponent(); + var c = colGo.GetComponent(); + dummy.cols.Add(new HierarchyColRow + { + colTransform = colT, + collider = c, + mesh = (c is MeshCollider mc2) ? mc2.sharedMesh + : (mf != null ? mf.sharedMesh : null), + typeLabel = c != null ? ColliderTypeLabel(c) : "Mesh", + isComponentOnly = false, + }); + } - if (!lodChildren.ContainsKey(lodIdx)) - lodChildren[lodIdx] = new List(); - lodChildren[lodIdx].Add(r); + // Pick up empty container transforms under root that aren't + // already represented (no LOD renderers, no _COL leaf). These + // are Dummies the user just added via "+ Add Dummy" — keep + // them in the view so the user can rename them, populate + // them, or remove them. + var colSetForEmpty = new HashSet(MeshHygieneUtility.FindCollisionObjects(root)); + for (int i = 0; i < root.childCount; i++) + { + var child = root.GetChild(i); + if (child == null) continue; + if (lookup.ContainsKey(child)) continue; + if (colSetForEmpty.Contains(child.gameObject)) continue; + if (child.GetComponent() != null) continue; + lookup[child] = new HierarchyDummy + { + dummy = child, + isRoot = false + }; } - if (lodChildren.Count == 0) return; + hierarchyDummies = lookup.Values.ToList(); + hierarchyDummies.Sort(CompareDummies); + foreach (var d in hierarchyDummies) + d.lods.Sort((a, b) => a.lodIndex.CompareTo(b.lodIndex)); + } - Undo.RecordObject(ctx.LodGroup, "Rebuild LODGroup"); + static HierarchyDummy GetOrCreateDummy(Dictionary lookup, + Transform key, Transform root) + { + if (!lookup.TryGetValue(key, out var dummy)) + { + dummy = new HierarchyDummy + { + dummy = key, + isRoot = key == root + }; + lookup[key] = dummy; + } + return dummy; + } - // Build contiguous LOD array from sorted keys. - // Transitions must be strictly descending for Unity's SetLODs. - int lodCount = lodChildren.Count; - var newLods = new LOD[lodCount]; - int idx = 0; - foreach (var kvp in lodChildren) + static string ColliderTypeLabel(Collider c) + { + switch (c) { - float screenHeight; - if (lodCount == 1) - screenHeight = 0.01f; - else - screenHeight = 1f - ((float)idx / (lodCount - 1)) * 0.99f; // 1.0 → 0.01 - newLods[idx] = new LOD(screenHeight, kvp.Value.ToArray()); - idx++; + case MeshCollider _: return "Mesh"; + case BoxCollider _: return "Box"; + case CapsuleCollider _: return "Capsule"; + case SphereCollider _: return "Sphere"; + case WheelCollider _: return "Wheel"; + case TerrainCollider _: return "Terrain"; + default: return c.GetType().Name.Replace("Collider", ""); } - ctx.LodGroup.SetLODs(newLods); - ctx.LodGroup.RecalculateBounds(); } - // ═══════════════════════════════════════════════════════════ - // Build Pipeline section: Open Prefab → Generate LODs (with - // progressive scaleInLightmap) → Validate → Save FBX. - // ═══════════════════════════════════════════════════════════ + static int CompareDummies(HierarchyDummy a, HierarchyDummy b) + { + if (a.isRoot && !b.isRoot) return -1; + if (!a.isRoot && b.isRoot) return 1; + return string.Compare(a.dummy.name, b.dummy.name, + System.StringComparison.OrdinalIgnoreCase); + } - void DrawBuildPipelineSection() + // ── Apply Changes: single commit point for every pending edit. ── + // Order matters: + // 1) Root/Dummy renames (so subsequent leaf-name rebuilds use the + // new prefix). + // 2) Pending deletes (frees up slots before inserts re-index). + // 3) Pending inserts (creates GameObjects + simplifies meshes; + // slot index recomputed from the captured afterRenderer's + // current LOD position). + // 4) Leaf rename rebuild ("_LOD{slot}"). + // All four happen inside a single Undo group so Ctrl+Z reverts the + // whole batch. + // + // The freshRendererIds set is intentionally NOT cleared here — the + // ★ NEW highlight should persist until the user selects a different + // prefab so they can still spot rows they added this session. + + void ApplyChanges() { - EditorGUILayout.Space(8); - buildFoldout = EditorGUILayout.Foldout(buildFoldout, "Build Pipeline", true); - if (!buildFoldout) return; + if (ctx == null || ctx.LodGroup == null) return; - if (ctx.LodGroup == null) + Undo.SetCurrentGroupName("Prefab Builder: Apply Changes"); + int undoGroup = Undo.GetCurrentGroup(); + + // 1) Root / Dummy renames. + if (pendingNames != null) { - EditorGUILayout.HelpBox("No LODGroup selected.", MessageType.Info); - return; + foreach (var kvp in pendingNames) + { + var go = EditorUtility.InstanceIDToObject(kvp.Key) as GameObject; + if (go == null || go.name == kvp.Value) continue; + Undo.RecordObject(go, "Rename"); + UvtLog.Info($"[LightmapUV] Renamed: {go.name} → {kvp.Value}"); + go.name = kvp.Value; + } + pendingNames.Clear(); } - DrawBuildOpenPrefab(); - EditorGUILayout.Space(6); - DrawBuildGenerateLods(); - EditorGUILayout.Space(6); - DrawBuildValidate(); - EditorGUILayout.Space(6); - DrawBuildSave(); - } + // 2) Wrap chains and add empty Dummies BEFORE inserts/deletes + // so insert anchors that reference the wrapped renderers + // still resolve to the right slot afterwards. + if (pendingWrapChains != null && pendingWrapChains.Count > 0) + CommitPendingWrapChains(); + if (pendingAddDummies != null && pendingAddDummies.Count > 0) + CommitPendingAddDummies(); - void DrawBuildOpenPrefab() - { - EditorGUILayout.LabelField("Open Prefab", EditorStyles.miniBoldLabel); + // 3) Pending deletes — process highest LOD index first to keep + // earlier indices stable during the loop. + if (pendingDeleteRendererIds != null && pendingDeleteRendererIds.Count > 0) + CommitPendingDeletes(); - var stage = PrefabStageUtility.GetCurrentPrefabStage(); - bool inStage = stage != null; - editInPrefabStage = inStage; + // 4) Pending inserts — recompute target slot index from each + // captured afterRenderer's CURRENT slot, so inserts after + // deletions land at the right place. + if (pendingInserts != null && pendingInserts.Count > 0) + CommitPendingInserts(); + + // 5) Leaf rename rebuild. + hierarchyDummies = null; + RebuildLeafNamesNoUndoGroup(); + + Undo.CollapseUndoOperations(undoGroup); + buildIntent |= FbxExportIntent.Hierarchy; - bool desired = EditorGUILayout.Toggle("Edit in isolated Prefab Stage", editInPrefabStage); + ctx.Refresh(ctx.LodGroup); + hierarchyDummies = null; + requestRepaint?.Invoke(); + } - string prefabPath = ResolvePrefabPathForEdit(); - using (new EditorGUI.DisabledScope(string.IsNullOrEmpty(prefabPath) && !inStage)) + // If the LODGroup root is part of a prefab instance, fully unpack it + // — otherwise SetTransformParent on prefab-instance children is a + // no-op and structural changes silently fail (which is exactly the + // bug that surfaced when "→ Dummy" wrapped without reparenting). + void EnsurePrefabUnpackedForStructuralEdit() + { + if (ctx?.LodGroup == null) return; + var lgGo = ctx.LodGroup.gameObject; + if (PrefabUtility.IsPartOfPrefabInstance(lgGo)) { - string label = desired == inStage - ? (inStage ? "Reload Prefab Stage" : "Open / Focus Prefab") - : (desired ? "Open Prefab Stage" : "Return to Main Stage"); - if (GUILayout.Button(label, GUILayout.Height(22))) - ApplyPrefabStage(desired, prefabPath, inStage); + var outer = PrefabUtility.GetOutermostPrefabInstanceRoot(lgGo); + if (outer != null) + { + UvtLog.Info($"[LightmapUV] Unpacking prefab instance '{outer.name}' before structural edit."); + PrefabUtility.UnpackPrefabInstance(outer, + PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + } } - - if (inStage) - EditorGUILayout.LabelField($"Stage: {System.IO.Path.GetFileName(stage.assetPath)}", EditorStyles.miniLabel); - else if (!string.IsNullOrEmpty(prefabPath)) - EditorGUILayout.LabelField($"Source: {System.IO.Path.GetFileName(prefabPath)}", EditorStyles.miniLabel); } - string ResolvePrefabPathForEdit() + void CommitPendingWrapChains() { - if (ctx.LodGroup == null) return null; - var go = ctx.LodGroup.gameObject; - if (PrefabUtility.IsPartOfPrefabInstance(go)) - return PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(go); - if (PrefabUtility.IsPartOfPrefabAsset(go)) - return AssetDatabase.GetAssetPath(go); - return null; + if (ctx?.LodGroup == null || pendingWrapChains == null) return; + EnsurePrefabUnpackedForStructuralEdit(); + var root = ctx.LodGroup.transform; + foreach (var pending in pendingWrapChains) + { + if (pending == null || pending.renderers == null || pending.renderers.Count == 0) continue; + string dummyName = pending.baseName; + if (root.Find(dummyName) != null) + { + int n = 1; + while (root.Find($"{dummyName}_{n}") != null) n++; + dummyName = $"{dummyName}_{n}"; + } + var newDummy = new GameObject(dummyName); + Undo.RegisterCreatedObjectUndo(newDummy, "Wrap chain in Dummy"); + newDummy.transform.SetParent(root, false); + int reparented = 0; + foreach (var r in pending.renderers) + { + if (r == null) continue; + Undo.SetTransformParent(r.transform, newDummy.transform, "Wrap LOD into Dummy"); + reparented++; + } + UvtLog.Info($"[LightmapUV] Wrapped chain '{pending.baseName}' in Dummy '{dummyName}' " + + $"({reparented} renderer(s) reparented)."); + } + pendingWrapChains.Clear(); + buildIntent |= FbxExportIntent.Hierarchy; } - void ApplyPrefabStage(bool wantStage, string prefabPath, bool currentlyInStage) + void CommitPendingAddDummies() { - if (!wantStage) + if (ctx?.LodGroup == null || pendingAddDummies == null) return; + EnsurePrefabUnpackedForStructuralEdit(); + var root = ctx.LodGroup.transform; + foreach (var pending in pendingAddDummies) { - if (currentlyInStage) StageUtility.GoToMainStage(); - UvtLog.Info("[LightmapUV] Switched to main stage."); - requestRepaint?.Invoke(); - return; + if (pending == null) continue; + string name = pending.name; + if (string.IsNullOrEmpty(name)) name = "Dummy"; + if (root.Find(name) != null) + { + int n = 1; + while (root.Find($"{name}_{n}") != null) n++; + name = $"{name}_{n}"; + } + var go = new GameObject(name); + Undo.RegisterCreatedObjectUndo(go, "Add Dummy"); + go.transform.SetParent(root, false); + UvtLog.Info($"[LightmapUV] Added empty Dummy '{name}' under '{root.name}'."); } - if (string.IsNullOrEmpty(prefabPath)) + pendingAddDummies.Clear(); + buildIntent |= FbxExportIntent.Hierarchy; + } + + void CommitPendingDeletes() + { + if (ctx?.LodGroup == null) return; + if (pendingDeleteRendererIds == null) return; + + // Collect the renderers and their slot indices. Process from the + // highest LOD slot down so removing entries doesn't invalidate + // the indices of the remaining ones. + var lodsBefore = ctx.LodGroup.GetLODs(); + var queue = new List<(int slot, Renderer renderer)>(); + for (int li = 0; li < lodsBefore.Length; li++) + { + if (lodsBefore[li].renderers == null) continue; + foreach (var r in lodsBefore[li].renderers) + { + if (r == null) continue; + if (pendingDeleteRendererIds.Contains(r.GetInstanceID())) + queue.Add((li, r)); + } + } + queue.Sort((a, b) => b.slot.CompareTo(a.slot)); + + foreach (var (slot, renderer) in queue) { - UvtLog.Warn("[LightmapUV] No prefab asset path resolved for current LODGroup."); - return; + var lods = ctx.LodGroup.GetLODs(); + if (slot < 0 || slot >= lods.Length) continue; + var slotData = lods[slot]; + var remaining = slotData.renderers != null + ? new List(slotData.renderers) : new List(); + remaining.Remove(renderer); + + if (remaining.Count > 0) + { + lods[slot] = new LOD(slotData.screenRelativeTransitionHeight, remaining.ToArray()); + LodGroupUtility.ApplyLods(ctx.LodGroup, lods); + } + else + { + if (lods.Length <= 1) + { + UvtLog.Warn("[LightmapUV] Skipping delete that would empty the LODGroup."); + continue; + } + var newLods = new LOD[lods.Length - 1]; + for (int i = 0, j = 0; i < lods.Length; i++) + { + if (i == slot) continue; + newLods[j++] = lods[i]; + } + LodGroupUtility.ApplyLods(ctx.LodGroup, newLods); + } + + if (renderer != null && renderer.gameObject != null) + { + UvtLog.Info($"[LightmapUV] Deleted '{renderer.name}' from slot {slot}."); + Undo.DestroyObjectImmediate(renderer.gameObject); + } } - var stage = PrefabStageUtility.OpenPrefab(prefabPath); - if (stage == null) { UvtLog.Warn($"[LightmapUV] Failed to open {prefabPath}"); return; } - var root = stage.prefabContentsRoot; - var lg = root != null ? root.GetComponentInChildren() : null; - if (lg != null) ctx.Refresh(lg); - UvtLog.Info($"[LightmapUV] Opened prefab {prefabPath}"); - requestRepaint?.Invoke(); + + pendingDeleteRendererIds.Clear(); + buildIntent |= FbxExportIntent.Hierarchy | FbxExportIntent.LodGroup; + ctx.LodGroup.RecalculateBounds(); + ctx.Refresh(ctx.LodGroup); + hierarchyDummies = null; } - void DrawBuildGenerateLods() + void CommitPendingInserts() { - EditorGUILayout.LabelField("Generate LODs", EditorStyles.miniBoldLabel); + if (ctx?.LodGroup == null) return; + if (pendingInserts == null) return; + + // Apply in REVERSE click order so earlier pendings still land in + // their intended position even after later pendings shift slot + // numbers. Slot index is re-resolved from each pending's live + // afterRenderer anchor; if the anchor was deleted earlier in the + // same Apply batch, fall back to the captured afterLodIndex + // clamped to the current LOD count. + for (int i = pendingInserts.Count - 1; i >= 0; i--) + { + var pending = pendingInserts[i]; + if (pending == null) continue; + int targetIdx = ResolvePendingInsertSlot(pending); + InsertSlotAtIndex(targetIdx, pending.quality); + } + pendingInserts.Clear(); + } - buildLodCount = EditorGUILayout.IntSlider("Count (new)", buildLodCount, 1, 4); - for (int i = 0; i < buildLodCount && i < buildLodRatios.Length; i++) + // Re-resolve the slot a pending insert should land in, against the + // live LODGroup. Prefers the captured afterRenderer's current slot + // (handles deletes-before-inserts in the same Apply batch); falls + // back to the originally captured afterLodIndex when the anchor was + // destroyed mid-batch. + int ResolvePendingInsertSlot(PendingInsert pending) + { + if (pending == null || ctx?.LodGroup == null) return 0; + if (pending.afterRenderer != null) { - float maxRatio = i == 0 ? 0.99f : buildLodRatios[i - 1] * 0.99f; - if (maxRatio < 0.001f) maxRatio = 0.001f; - if (buildLodRatios[i] > maxRatio) buildLodRatios[i] = maxRatio * 0.5f; - buildLodRatios[i] = EditorGUILayout.Slider($" LOD{i + 1} ratio", buildLodRatios[i], 0.001f, maxRatio); + var lods = ctx.LodGroup.GetLODs(); + for (int slot = 0; slot < lods.Length; slot++) + { + var rs = lods[slot].renderers; + if (rs == null) continue; + foreach (var r in rs) + if (r == pending.afterRenderer) + return slot + 1; + } } - - buildTargetError = EditorGUILayout.Slider("Target Error", buildTargetError, 0.001f, 0.5f); - buildUv2Weight = EditorGUILayout.Slider("UV2 Weight", buildUv2Weight, 0f, 500f); - buildNormalWeight = EditorGUILayout.Slider("Normal Weight", buildNormalWeight, 0f, 10f); - buildLockBorder = EditorGUILayout.Toggle("Lock Border", buildLockBorder); - - var preview = new System.Text.StringBuilder("scaleInLightmap: LOD0=inherit"); - for (int i = 1; i <= buildLodCount; i++) - preview.Append($", LOD{i}={Mathf.Pow(0.5f, i):F3}"); - EditorGUILayout.LabelField(preview.ToString(), EditorStyles.miniLabel); - - var bg = GUI.backgroundColor; - GUI.backgroundColor = new Color(.7f, .4f, .95f); - if (GUILayout.Button("Generate LODs", GUILayout.Height(26))) - ExecBuildGenerateLods(); - GUI.backgroundColor = bg; + int currentLodCount = ctx.LodGroup.GetLODs().Length; + return Mathf.Clamp(pending.afterLodIndex + 1, 0, currentLodCount); } - void ExecBuildGenerateLods() + bool HasAnyStaleLeafName() { - int startLod = 1; - var lods = ctx.LodGroup.GetLODs(); - for (int li = 0; li < lods.Length; li++) - if (lods[li].renderers != null && lods[li].renderers.Length > 0) - startLod = li + 1; - if (startLod == 0) startLod = 1; - - var opts = new LodPipelineOps.Options - { - count = buildLodCount, - ratios = buildLodRatios, - targetError = buildTargetError, - uv2Weight = buildUv2Weight, - normalWeight = buildNormalWeight, - lockBorder = buildLockBorder, - progressiveScaleInLightmap = true - }; - var result = LodPipelineOps.Generate(ctx, startLod, opts); - if (!result.ok) - { - UvtLog.Error($"[Build] Generate failed: {result.error}"); - return; - } - // Generated LODs add fresh meshes with their own UVs / colors / - // normals / tangents and grow the LODGroup component. - buildIntent |= FbxExportIntent.Hierarchy - | FbxExportIntent.LodGroup - | FbxExportIntent.AnyUv - | FbxExportIntent.VertexColors - | FbxExportIntent.Normals - | FbxExportIntent.Tangents; - buildIssues = null; - requestRepaint?.Invoke(); + if (hierarchyDummies == null) RebuildHierarchyView(); + if (hierarchyDummies == null) return false; + foreach (var d in hierarchyDummies) + if (DummyHasStale(d)) return true; + return false; } - void DrawBuildValidate() + // ── LOD row operations ── + + void RegenerateLodWithQuality(HierarchyDummy dummy, HierarchyLodRow lod) { - EditorGUILayout.LabelField("Validate", EditorStyles.miniBoldLabel); + if (ctx.LodGroup == null || dummy == null || lod == null || lod.renderer == null) + return; - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Validate", GUILayout.Height(22))) - buildIssues = BuildValidator.Run(ctx); - using (new EditorGUI.DisabledScope(buildIssues == null || buildIssues.Count == 0)) - { - if (GUILayout.Button("Clear", GUILayout.Width(80), GUILayout.Height(22))) - buildIssues = null; - } - EditorGUILayout.EndHorizontal(); + int rid = lod.renderer.GetInstanceID(); + float quality = lodQualitySliders.TryGetValue(rid, out var q) ? q + : Mathf.Pow(0.5f, lod.lodIndex); - if (buildIssues == null) return; - if (buildIssues.Count == 0) + // LOD0 source = matching renderer in this dummy's LOD0 row, or fall back + // to the first LOD0 renderer in the LODGroup. We match by stripped group + // key so renames mid-pipeline don't break the link. + var sourceMesh = ResolveLodSourceMesh(dummy, lod); + if (sourceMesh == null) { - EditorGUILayout.HelpBox("No issues found.", MessageType.Info); + UvtLog.Warn($"[LightmapUV] Regenerate LOD{lod.lodIndex}: no source mesh."); return; } - int blockers = buildIssues.Count(i => BuildValidator.IsBlocker(i.group)); - int warns = buildIssues.Count - blockers; - EditorGUILayout.LabelField($"Total: {buildIssues.Count} (blockers: {blockers}, warnings: {warns})", - EditorStyles.miniLabel); + // Pull simplifier weights from the right-sidebar Settings panel + // (LodGenerationTool's tunables). Falls back to a sensible default + // when the Hub / tool isn't available. + var settings = ResolveSimplifierSettings(quality); - foreach (BuildValidator.IssueGroup grp in System.Enum.GetValues(typeof(BuildValidator.IssueGroup))) + var res = MeshSimplifier.Simplify(sourceMesh, settings); + if (!res.ok) { - var inGroup = buildIssues.Where(i => i.group == grp).ToList(); - if (inGroup.Count == 0) continue; - if (!buildIssueFoldouts.TryGetValue(grp, out var open)) open = true; - string badge = BuildValidator.IsBlocker(grp) ? "✖" : "⚠"; - open = EditorGUILayout.Foldout(open, $" {badge} {grp} ({inGroup.Count})", true); - buildIssueFoldouts[grp] = open; - if (!open) continue; - foreach (var issue in inGroup) - { - EditorGUILayout.BeginHorizontal(); - EditorGUILayout.LabelField($" {issue.meshName}: {issue.detail}", EditorStyles.miniLabel); - using (new EditorGUI.DisabledScope(issue.target == null)) - { - if (GUILayout.Button("Ping", GUILayout.Width(40), GUILayout.Height(16))) - EditorGUIUtility.PingObject(issue.target); - } - EditorGUILayout.EndHorizontal(); - } + UvtLog.Warn($"[LightmapUV] Simplify failed for '{sourceMesh.name}': {res.error}"); + return; } - } + res.simplifiedMesh.name = sourceMesh.name + "_LOD" + lod.lodIndex; - void DrawBuildSave() - { - EditorGUILayout.LabelField("Save", EditorStyles.miniBoldLabel); + var mf = lod.renderer.GetComponent(); + if (mf == null) return; + // Snapshot the original mesh BEFORE swapping. ctx.Refresh below + // would otherwise rewrite MeshEntry.fbxMesh to point at the + // simplified mesh, defeating the Discard affordance the next + // time the user wants to revert. + if (regenBackupMeshes != null + && !regenBackupMeshes.ContainsKey(rid) + && mf.sharedMesh != null) + regenBackupMeshes[rid] = mf.sharedMesh; - EditorGUILayout.BeginHorizontal(); - var bg = GUI.backgroundColor; - GUI.backgroundColor = new Color(.4f, .8f, .4f); - if (GUILayout.Button("Overwrite Source FBX", GUILayout.Height(26))) - ExecBuildSave(overwriteSource: true); - GUI.backgroundColor = new Color(.3f, .7f, 1f); - if (GUILayout.Button("Save As New FBX…", GUILayout.Height(26))) - ExecBuildSave(overwriteSource: false); - GUI.backgroundColor = bg; - EditorGUILayout.EndHorizontal(); + Undo.RecordObject(mf, "Regenerate LOD"); + mf.sharedMesh = res.simplifiedMesh; + + // Drop the stored slider value so the row recomputes its ratio + // from the new polygon counts on the next paint; otherwise the + // slider keeps showing the requested quality even when the + // simplifier missed the target. + lodQualitySliders?.Remove(rid); + freshRendererIds?.Add(rid); + + UvtLog.Info($"[LightmapUV] Regenerated LOD{lod.lodIndex} on '{lod.renderer.name}': " + + $"{res.originalTriCount} → {res.simplifiedTriCount} tris (target {quality:P0})"); + + buildIntent |= FbxExportIntent.AnyUv | FbxExportIntent.Normals + | FbxExportIntent.Tangents | FbxExportIntent.VertexColors; + + ctx.LodGroup.RecalculateBounds(); + ctx.Refresh(ctx.LodGroup); + hierarchyDummies = null; + requestRepaint?.Invoke(); } - void ExecBuildSave(bool overwriteSource) + Mesh ResolveLodSourceMesh(HierarchyDummy dummy, HierarchyLodRow lod) { - if (buildIssues == null) buildIssues = BuildValidator.Run(ctx); - var blockers = buildIssues.Where(i => BuildValidator.IsBlocker(i.group)).ToList(); - if (blockers.Count > 0) - { - string msg = "Fix blocking issues first:\n\n" + - string.Join("\n", blockers.Take(6).Select(b => $"• [{b.group}] {b.meshName}: {b.detail}")); - if (blockers.Count > 6) msg += $"\n… +{blockers.Count - 6} more"; - EditorUtility.DisplayDialog("Build blocked", msg, "OK"); - return; + // Always prefer the import-time fbxMesh from MeshEntries over the + // currently-bound sharedMesh. Otherwise re-running simplify on + // LOD0 would replace its mesh with a degraded copy and every + // downstream regenerate (LOD1 → LODN) would start from that + // already-simplified geometry instead of the original FBX. + string key = lod != null && lod.renderer != null + ? UvToolContext.ExtractGroupKey(lod.renderer.name) + : null; + if (key != null) + { + foreach (var candidate in dummy.lods) + { + if (candidate == lod) continue; + if (candidate.lodIndex != 0) continue; + if (candidate.mesh == null) continue; + string ck = UvToolContext.ExtractGroupKey(candidate.renderer != null ? candidate.renderer.name : ""); + if (string.Equals(ck, key, System.StringComparison.OrdinalIgnoreCase)) + return PreferOriginalMesh(candidate.renderer, candidate.mesh); + } } + // For LOD0 → reuse the renderer's own mesh, but always go through + // the original-mesh lookup so we don't simplify a simplified mesh. + if (lod != null && lod.lodIndex == 0) + return PreferOriginalMesh(lod.renderer, lod.mesh); + // Fallback 1: first LOD0 mesh in the dummy. + foreach (var candidate in dummy.lods) + if (candidate.lodIndex == 0 && candidate.mesh != null) + return PreferOriginalMesh(candidate.renderer, candidate.mesh); + // Fallback 2: empty dummies (just added via "+ Add Dummy" with + // no children yet) borrow LOD0 from the first sibling dummy + // that has one — gives the user a working seed they can swap + // out later via the inspector. + if (hierarchyDummies != null) + { + foreach (var sibling in hierarchyDummies) + { + if (sibling == null || sibling == dummy) continue; + foreach (var candidate in sibling.lods) + if (candidate.lodIndex == 0 && candidate.mesh != null) + return PreferOriginalMesh(candidate.renderer, candidate.mesh); + } + } + return null; + } - var hubs = Resources.FindObjectsOfTypeAll(); - var hub = hubs != null && hubs.Length > 0 ? hubs[0] : null; - var transferTool = hub != null ? hub.FindTool() : null; - if (transferTool == null) + // Look up the import-time mesh (fbxMesh) for a renderer via the + // shared MeshEntries cache. Falls back to the supplied current + // sharedMesh when the entry doesn't carry an FBX-source reference. + Mesh PreferOriginalMesh(Renderer r, Mesh fallback) + { + if (r == null || ctx?.MeshEntries == null) return fallback; + foreach (var e in ctx.MeshEntries) { - UvtLog.Error("[Build] UV2 Transfer tool not found — cannot export FBX."); - return; + if (e.renderer != r) continue; + return e.fbxMesh ?? e.originalMesh ?? fallback; } - // Build pipeline tracks which channels its operations touched - // since the last refresh. If nothing was tracked we conservatively - // fall back to the wide path (intent=All) — Save can be hit - // after edits made by other tools (UV2 transfer, vertex color - // baking, etc.) that we don't observe from here. - var intent = buildIntent != FbxExportIntent.None - ? buildIntent - : FbxExportIntent.All; - transferTool.ExportFbxPublic(overwriteSource, intent); - buildIntent = FbxExportIntent.None; - buildIssues = null; + return fallback; } - // ═══════════════════════════════════════════════════════════ - // LOD management section - // ═══════════════════════════════════════════════════════════ - - void DrawLodManagementSection() + // Insert a new LOD slot at index targetIdx and create a simplified + // renderer in EVERY Dummy that has a LOD0 source mesh. Synced inserts + // keep multi-Dummy prefabs intact — Stove_Cap doesn't silently drop + // out at the new camera distance just because the user clicked + // "+ Add LOD" on the Stove_Base block. + void InsertSlotAtIndex(int insertIndex, float requestedQuality) { - EditorGUILayout.Space(8); - lodFoldout = EditorGUILayout.Foldout(lodFoldout, "LOD Levels", true); - if (!lodFoldout) return; + if (ctx?.LodGroup == null) return; - if (ctx.LodGroup == null) - { - EditorGUILayout.HelpBox("No LODGroup selected.", MessageType.Info); - return; - } + Undo.SetCurrentGroupName("Prefab Builder: Insert LOD slot"); + int undoGroup = Undo.GetCurrentGroup(); + + // Compact pre-existing empty slots before splicing so the new slot + // index doesn't get pushed past a phantom gap. + UvToolContext.CompactLodArray(ctx.LodGroup, removeEmptySlots: true); + if (hierarchyDummies == null) RebuildHierarchyView(); var lods = ctx.LodGroup.GetLODs(); - bool changed = false; + int targetIdx = Mathf.Clamp(insertIndex, 0, lods.Length); - for (int li = 0; li < lods.Length; li++) - { - var renderers = lods[li].renderers; - int rendCount = 0; - int totalVerts = 0; - if (renderers != null) - { - foreach (var r in renderers) - { - if (r == null) continue; - rendCount++; - var mf = r.GetComponent(); - if (mf != null && mf.sharedMesh != null) - totalVerts += mf.sharedMesh.vertexCount; - } - } + float prevTrans = targetIdx > 0 + ? lods[targetIdx - 1].screenRelativeTransitionHeight : 1f; + float nextTrans = targetIdx < lods.Length + ? lods[targetIdx].screenRelativeTransitionHeight : 0.01f; + float newTrans = Mathf.Max(0.01f, (prevTrans + nextTrans) * 0.5f); - EditorGUILayout.BeginHorizontal(); + float quality = requestedQuality > 0f ? requestedQuality : 0.5f; - // LOD label - EditorGUILayout.LabelField($"LOD{li}", EditorStyles.boldLabel, GUILayout.Width(42)); + var newRenderers = new List(); + int totalOrigTris = 0; + int totalSimplTris = 0; - // Transition slider - float oldTrans = lods[li].screenRelativeTransitionHeight; - float newTrans = EditorGUILayout.Slider(oldTrans, 0.001f, 1f); - if (Mathf.Abs(newTrans - oldTrans) > 0.0001f) + foreach (var dummy in hierarchyDummies) + { + if (dummy == null || dummy.dummy == null) continue; + + var sourceMesh = ResolveLodSourceMesh(dummy, + dummy.lods.Count > 0 ? dummy.lods[0] : new HierarchyLodRow { lodIndex = 0 }); + if (sourceMesh == null) continue; + + var settings = ResolveSimplifierSettings(quality); + var res = MeshSimplifier.Simplify(sourceMesh, settings); + if (!res.ok) { - lods[li].screenRelativeTransitionHeight = newTrans; - changed = true; + UvtLog.Warn($"[LightmapUV] Insert slot simplify failed for '{sourceMesh.name}': {res.error}"); + continue; } - // Stats - EditorGUILayout.LabelField($"{rendCount}r {totalVerts:N0}v", - EditorStyles.miniLabel, GUILayout.Width(80)); - - // Per-row Regenerate button (LOD >= 1 only — LOD0 is the source). - if (li > 0) + // Pick a base name for the new mesh + GameObject: + // 1) prefer this dummy's existing LOD0 chain base — keeps + // naming stable when inserting into a populated chain; + // 2) fall back to the dummy GameObject's name when the + // dummy is empty (just added via "+ Add Dummy") and + // it isn't the implicit Root group; + // 3) last resort, use the source mesh's stripped name. + string baseName = null; + foreach (var existing in dummy.lods) + { + if (existing?.renderer == null) continue; + if (existing.lodIndex != 0) continue; + string b = UvToolContext.ExtractGroupKey(existing.renderer.name); + if (!string.IsNullOrEmpty(b)) { baseName = b; break; } + } + if (string.IsNullOrEmpty(baseName) && dummy.dummy != null && !dummy.isRoot) + baseName = dummy.dummy.name; + if (string.IsNullOrEmpty(baseName)) + baseName = UvToolContext.ExtractGroupKey(sourceMesh.name); + if (string.IsNullOrEmpty(baseName)) baseName = sourceMesh.name; + res.simplifiedMesh.name = baseName + "_LOD" + targetIdx; + + Transform parent = dummy.dummy != null ? dummy.dummy : ctx.LodGroup.transform; + var go = new GameObject(baseName + "_LOD" + targetIdx); + Undo.RegisterCreatedObjectUndo(go, "Insert LOD slot"); + go.transform.SetParent(parent, false); + + // Sibling-position the new GO right after this dummy's + // previous-LOD sibling (or before the next-LOD sibling) so the + // scene hierarchy mirrors the LODGroup slot order. + int desiredSibling = -1; + if (targetIdx > 0) { - GUI.backgroundColor = new Color(.6f, .75f, .9f); - if (GUILayout.Button(new GUIContent("\u21BB", - "Regenerate this LOD mesh from LOD0 via mesh simplifier."), - GUILayout.Width(22), GUILayout.Height(18))) + foreach (var candidate in dummy.lods) { - RegenerateLod(li); - return; // UI invalidated + if (candidate?.renderer == null) continue; + if (candidate.lodIndex != targetIdx - 1) continue; + if (candidate.renderer.transform.parent != parent) continue; + desiredSibling = candidate.renderer.transform.GetSiblingIndex() + 1; + break; } - GUI.backgroundColor = Color.white; } - - // Remove LOD button - if (lods.Length > 1) + if (desiredSibling < 0) { - GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f); - if (GUILayout.Button("X", GUILayout.Width(22), GUILayout.Height(18))) + foreach (var candidate in dummy.lods) { - RemoveLodLevel(li); - return; // UI is invalidated, exit early + if (candidate?.renderer == null) continue; + if (candidate.lodIndex < targetIdx) continue; + if (candidate.renderer.transform.parent != parent) continue; + desiredSibling = candidate.renderer.transform.GetSiblingIndex(); + break; } - GUI.backgroundColor = Color.white; } + if (desiredSibling >= 0) + go.transform.SetSiblingIndex(desiredSibling); - EditorGUILayout.EndHorizontal(); + var mf = go.AddComponent(); + mf.sharedMesh = res.simplifiedMesh; + var mr = go.AddComponent(); - // Per-renderer list with move buttons - if (renderers != null) - { - for (int ri = 0; ri < renderers.Length; ri++) - { - var r = renderers[ri]; - if (r == null) continue; + Renderer sourceRenderer = null; + foreach (var candidate in dummy.lods) + if (candidate.lodIndex == 0 && candidate.renderer != null) + { sourceRenderer = candidate.renderer; break; } + if (sourceRenderer != null) + LightmapTransferTool.CopyRendererSettings(sourceRenderer, mr); - EditorGUILayout.BeginHorizontal(); - GUILayout.Space(48); + int newRid = mr.GetInstanceID(); + // Don't seed lodQualitySliders with `quality` — leave the + // slot empty so the slider recomputes from the actual + // polygon ratio next paint (which may differ from the + // requested ratio when the simplifier hits Target Error + // before reaching the target tri count). + freshRendererIds?.Add(newRid); - EditorGUILayout.LabelField(r.name, EditorStyles.miniLabel); + newRenderers.Add(mr); + totalOrigTris += res.originalTriCount; + totalSimplTris += res.simplifiedTriCount; + } - // Move up (to previous LOD) - GUI.enabled = li > 0; - if (GUILayout.Button("\u25B2", GUILayout.Width(22), GUILayout.Height(16))) - { - MoveRendererBetweenLods(r, li, li - 1); - return; - } - GUI.enabled = true; + if (newRenderers.Count == 0) + { + UvtLog.Warn("[LightmapUV] Insert slot: no dummy had a LOD0 source mesh; nothing inserted."); + Undo.CollapseUndoOperations(undoGroup); + return; + } - // Move down (to next LOD) - GUI.enabled = li < lods.Length - 1; - if (GUILayout.Button("\u25BC", GUILayout.Width(22), GUILayout.Height(16))) - { - MoveRendererBetweenLods(r, li, li + 1); - return; - } - GUI.enabled = true; + // Splice into LODs[]: shift renderers from targetIdx onward down. + var newLods = new LOD[lods.Length + 1]; + for (int i = 0; i < targetIdx; i++) newLods[i] = lods[i]; + newLods[targetIdx] = new LOD(newTrans, newRenderers.ToArray()); + for (int i = targetIdx; i < lods.Length; i++) newLods[i + 1] = lods[i]; + LodGroupUtility.ApplyLods(ctx.LodGroup, newLods); - EditorGUILayout.EndHorizontal(); - } - } - } + buildIntent |= FbxExportIntent.Hierarchy | FbxExportIntent.LodGroup + | FbxExportIntent.AnyUv | FbxExportIntent.Normals + | FbxExportIntent.Tangents | FbxExportIntent.VertexColors; - if (changed) - LodGroupUtility.ApplyLods(ctx.LodGroup, lods); + ctx.LodGroup.RecalculateBounds(); + ctx.Refresh(ctx.LodGroup); + hierarchyDummies = null; - // Add LOD button - EditorGUILayout.Space(4); - var bgc = GUI.backgroundColor; - GUI.backgroundColor = new Color(0.6f, 0.75f, 0.9f); - if (GUILayout.Button("+ Add LOD Level", GUILayout.Height(22))) - AddLodLevel(); - GUI.backgroundColor = bgc; + Undo.CollapseUndoOperations(undoGroup); + + UvtLog.Info($"[LightmapUV] Inserted LOD slot {targetIdx} across {newRenderers.Count} dummies " + + $"(quality {quality:P0}, {totalOrigTris} → {totalSimplTris} tris total)"); } - void AddLodLevel() + void DeleteCol(HierarchyDummy dummy, HierarchyColRow col) { - if (ctx.LodGroup == null) return; + if (col == null) return; - var lods = ctx.LodGroup.GetLODs(); - var newLods = new LOD[lods.Length + 1]; - System.Array.Copy(lods, newLods, lods.Length); + if (col.isComponentOnly) + { + if (col.collider == null) return; + UvtLog.Info($"[LightmapUV] Removed {col.typeLabel}Collider from '{col.colTransform.name}'."); + Undo.DestroyObjectImmediate(col.collider); + } + else + { + if (col.colTransform == null) return; + UvtLog.Info($"[LightmapUV] Removed COL '{col.colTransform.name}' from '{dummy.dummy.name}'."); + Undo.DestroyObjectImmediate(col.colTransform.gameObject); + } - float lastTrans = lods.Length > 0 ? lods[lods.Length - 1].screenRelativeTransitionHeight : 0.5f; - newLods[lods.Length] = new LOD(lastTrans * 0.5f, new Renderer[0]); + buildIntent |= FbxExportIntent.Hierarchy | FbxExportIntent.Collision; - LodGroupUtility.ApplyLods(ctx.LodGroup, newLods); - ctx.Refresh(ctx.LodGroup); + if (ctx.LodGroup != null) ctx.Refresh(ctx.LodGroup); + hierarchyDummies = null; requestRepaint?.Invoke(); - UvtLog.Info($"Added LOD{lods.Length} (transition: {lastTrans * 0.5f:F3})"); } - void RemoveLodLevel(int lodIndex) + // ── Leaf rename: only fix the trailing _LOD{N} / _COL[_Hull{N}] suffix. + // Each leaf keeps its existing base name (e.g. "Stove_Base_LOD0" stays + // "Stove_Base_LOD0", not "Stove_LOD0") — the user controls the base by + // editing GameObject names directly; we just renumber slots so that + // Insert / Delete don't leave duplicates like two "*_LOD2"s. + // Caller owns the surrounding Undo group; rename ops are recorded with + // Undo.RecordObject so they collapse with the triggering mutation. + void RebuildLeafNamesNoUndoGroup() { - if (ctx.LodGroup == null) return; + if (ctx?.LodGroup == null) return; + if (hierarchyDummies == null) RebuildHierarchyView(); + if (hierarchyDummies == null) return; - var lods = ctx.LodGroup.GetLODs(); - if (lodIndex < 0 || lodIndex >= lods.Length) return; - - var newLods = new LOD[lods.Length - 1]; - for (int i = 0, j = 0; i < lods.Length; i++) + string rootName = ctx.LodGroup.gameObject.name; + foreach (var dummy in hierarchyDummies) { - if (i == lodIndex) continue; - newLods[j++] = lods[i]; - } + if (dummy.dummy == null) continue; + string fallbackBase = dummy.isRoot ? rootName : dummy.dummy.name; + if (string.IsNullOrEmpty(fallbackBase)) fallbackBase = "Group"; - LodGroupUtility.ApplyLods(ctx.LodGroup, newLods); - ctx.LodGroup.RecalculateBounds(); - ctx.Refresh(ctx.LodGroup); - requestRepaint?.Invoke(); - UvtLog.Info($"Removed LOD{lodIndex}"); + for (int i = 0; i < dummy.lods.Count; i++) + { + var lod = dummy.lods[i]; + if (lod.renderer == null) continue; + string current = lod.renderer.gameObject.name; + string baseName = UvToolContext.ExtractGroupKey(current); + if (string.IsNullOrEmpty(baseName)) baseName = fallbackBase; + string desired = $"{baseName}_LOD{lod.lodIndex}"; + if (current == desired) continue; + Undo.RecordObject(lod.renderer.gameObject, "Rename LOD"); + lod.renderer.gameObject.name = desired; + } + + // Count standalone _COL GameObjects to pick single vs Hull{N}. + int standaloneCount = 0; + foreach (var c in dummy.cols) + if (!c.isComponentOnly) standaloneCount++; + int hullIndex = 0; + for (int i = 0; i < dummy.cols.Count; i++) + { + var col = dummy.cols[i]; + if (col.colTransform == null) continue; + if (col.isComponentOnly) continue; + string current = col.colTransform.gameObject.name; + string baseName = UvToolContext.ExtractGroupKey(current); + if (string.IsNullOrEmpty(baseName)) baseName = fallbackBase; + string desired = standaloneCount <= 1 + ? $"{baseName}_COL" + : $"{baseName}_COL_Hull{hullIndex}"; + hullIndex++; + if (current == desired) continue; + Undo.RecordObject(col.colTransform.gameObject, "Rename COL"); + col.colTransform.gameObject.name = desired; + } + } } - // Regenerate a single LOD's meshes from the matching LOD0 source via - // mesh simplifier. Preserves existing LOD renderer GameObjects — we - // just swap mf.sharedMesh — so prefab-instance structure stays - // intact. Matches sources by stripped group key (UvToolContext. - // ExtractGroupKey), so LOD0 'Wall' pairs with LODN 'Wall_LOD2', etc. - void RegenerateLod(int lodIndex) + // ── Chain foldout header (Unity Hierarchy-style chevron). ── + // Returns true when the chain is expanded. Persists per-chain + // state in collapsedChains keyed by dummy + base name. + bool DrawChainFoldoutHeader(HierarchyDummy dummy, HierarchyChain chain) { - if (ctx.LodGroup == null) return; - if (lodIndex <= 0) - { - UvtLog.Warn("[LOD] Can't regenerate LOD0 — it is the source."); - return; - } - var lods = ctx.LodGroup.GetLODs(); - if (lodIndex >= lods.Length) - { - UvtLog.Warn($"[LOD] Invalid LOD index {lodIndex}."); - return; - } + string key = (dummy.dummy != null ? dummy.dummy.GetInstanceID() : 0) + + "|" + chain.baseName; + bool open = collapsedChains == null || !collapsedChains.Contains(key); - var lod0 = lods[0].renderers ?? new Renderer[0]; - var lodN = lods[lodIndex].renderers ?? new Renderer[0]; - if (lod0.Length == 0 || lodN.Length == 0) - { - UvtLog.Warn($"[LOD] Regenerate LOD{lodIndex}: empty source or target."); - return; + EditorGUILayout.BeginHorizontal(); + // Chevron at the dummy content edge. + var chevRect = GUILayoutUtility.GetRect(14, 16, + GUILayout.Width(14), GUILayout.Height(16)); + bool nextOpen = EditorGUI.Foldout(chevRect, open, GUIContent.none, true); + EditorGUILayout.LabelField(chain.baseName, EditorStyles.boldLabel, + GUILayout.MinWidth(80)); + GUILayout.FlexibleSpace(); + // Channel badges sourced from LOD0's mesh — chain-wide channels + // are uniform in practice (regenerate preserves them; transfer + // copies them), so showing UV0·UV1·N·T once per chain replaces + // the per-row repetition we used to render below the name. + string chainBadges = chain.rows.Count > 0 + ? ChannelBadges(chain.rows[0].mesh) + : ""; + string summary = string.IsNullOrEmpty(chainBadges) + ? $"{chain.rows.Count} LOD" + : $"{chain.rows.Count} LOD · {chainBadges}"; + EditorGUILayout.LabelField(summary, + EditorStyles.miniLabel, GUILayout.Width(140)); + // "→ Dummy" wraps a flat-under-Root chain in a fresh GameObject + // container named after the chain base. The chain renderers get + // re-parented under it; LODGroup references stay intact since + // they track Renderer components, not parents. Only offered + // when the chain currently lives directly under Root (already- + // nested chains skip the affordance). + if (dummy.isRoot) + { + bool wrapPending = IsChainWrapPending(chain); + var prevBg2 = GUI.backgroundColor; + GUI.backgroundColor = wrapPending + ? new Color(0.95f, 0.65f, 0.20f) // amber — queued + : new Color(0.55f, 0.85f, 0.55f); // green — idle + string label = wrapPending ? "✓ Queued" : "→ Dummy"; + string tooltip = wrapPending + ? $"Wrap '{chain.baseName}' chain queued. Click to cancel before Apply Changes." + : $"Queue: wrap '{chain.baseName}' chain in a new Dummy GameObject under Root. " + + "Applied on Apply Changes."; + if (GUILayout.Button(new GUIContent(label, tooltip), + EditorStyles.miniButton, GUILayout.Width(74))) + { + ToggleChainWrapPending(chain); + } + GUI.backgroundColor = prevBg2; } + GUILayout.Space(ChainContentRightPad); + EditorGUILayout.EndHorizontal(); - // Index LOD0 source meshes by stripped base name. - var sourceByKey = new System.Collections.Generic.Dictionary(); - foreach (var r in lod0) + if (nextOpen != open) { - if (r == null) continue; - var mf = r.GetComponent(); - if (mf == null || mf.sharedMesh == null) continue; - var key = UvToolContext.ExtractGroupKey(r.name); - if (!string.IsNullOrEmpty(key)) - sourceByKey[key] = mf.sharedMesh; + if (collapsedChains == null) collapsedChains = new HashSet(); + if (nextOpen) collapsedChains.Remove(key); + else collapsedChains.Add(key); } + return nextOpen; + } - if (sourceByKey.Count == 0) + // ── Chain grouping ── + // Split a Dummy's LOD rows into chains keyed by stripped base name + // (Metal_LOD0 and Metal_LOD1 share base "Metal", so they form one + // chain). Chains are returned in first-seen order so the visual + // layout follows the LODGroup's slot-0 ordering. Within each chain, + // rows are sorted by lodIndex so LODs read top-to-bottom. + sealed class HierarchyChain + { + public string baseName; + public List rows = new List(); + } + static List GroupLodsByChain(HierarchyDummy dummy) + { + var byKey = new Dictionary(); + var ordered = new List(); + foreach (var lod in dummy.lods) { - UvtLog.Warn($"[LOD] Regenerate LOD{lodIndex}: no LOD0 source meshes found."); - return; + if (lod == null) continue; + string key = "(unnamed)"; + if (lod.renderer != null && !string.IsNullOrEmpty(lod.renderer.name)) + { + string stripped = UvToolContext.ExtractGroupKey(lod.renderer.name); + key = string.IsNullOrEmpty(stripped) ? lod.renderer.name : stripped; + } + if (!byKey.TryGetValue(key, out var chain)) + { + chain = new HierarchyChain { baseName = key }; + byKey[key] = chain; + ordered.Add(chain); + } + chain.rows.Add(lod); } + foreach (var chain in ordered) + chain.rows.Sort((a, b) => a.lodIndex.CompareTo(b.lodIndex)); + return ordered; + } - // Ratio: 0.5^lodIndex against LOD0. Reasonable default for most - // pipelines; user can tweak via LodGen tab for finer control. - float ratio = Mathf.Clamp(Mathf.Pow(0.5f, lodIndex), 0.05f, 0.95f); - var settings = new MeshSimplifier.SimplifySettings - { - targetRatio = ratio, - targetError = 0.1f, - uv2Weight = 0.5f, - normalWeight = 0.5f, - lockBorder = true, - uvChannel = 1, - }; + // ── Visual divider between LOD rows so the table reads as discrete entries. ── + // The tinted chain helpBox washes out faint dividers, so the rule + // here is darker (full alpha) and 1 px tall. + static void DrawRowDivider() + { + EditorGUILayout.Space(2); + var rect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true)); + if (Event.current.type == EventType.Repaint) + EditorGUI.DrawRect(rect, new Color(0.18f, 0.18f, 0.18f, 0.85f)); + EditorGUILayout.Space(2); + } + + // ── Status marker: small coloured square painted at the start of a row. ── + // Drawn via GetRect+DrawRect (a guaranteed-rendered rect from the layout) + // so the indicator survives nested helpBox layouts that swallow post-paint + // strokes elsewhere. + static void DrawStatusMarker(bool fresh, bool stale, bool markedDelete = false) + { + const float w = 6f; + const float h = 18f; + var rect = GUILayoutUtility.GetRect(w, h, GUILayout.Width(w), GUILayout.Height(h)); + if (Event.current.type != EventType.Repaint) return; + Color color; + if (markedDelete) color = new Color(1f, 0.20f, 0.20f); // red — pending delete + else if (fresh) color = new Color(1f, 0.50f, 0.05f); // bright orange — just inserted / regenerated + else if (stale) color = new Color(0.95f, 0.75f, 0.20f); // amber — name out of sync + else color = new Color(0.30f, 0.30f, 0.30f, 0.35f); // subtle gutter + EditorGUI.DrawRect(new Rect(rect.x, rect.y + 2, w - 1, h - 4), color); + } + + // True when the row's renderer was regenerated this session and its + // current mesh differs from the captured original — i.e. Discard + // would actually change something. The backup is captured BEFORE + // the first regenerate (see RegenerateLodWithQuality), so this stays + // valid across the ctx.Refresh that runs immediately after. + bool RowWasRegenerated(HierarchyLodRow lod) + { + if (lod == null || lod.renderer == null) return false; + if (regenBackupMeshes == null) return false; + int rid = lod.renderer.GetInstanceID(); + if (!regenBackupMeshes.TryGetValue(rid, out var backup) || backup == null) + return false; + var mf = lod.renderer.GetComponent(); + if (mf == null) return false; + return mf.sharedMesh != backup; + } + + // Restore the renderer's MeshFilter back to the captured original + // mesh and drop the row from both the freshRendererIds highlight and + // the backup map. + void DiscardRegenerate(HierarchyLodRow lod) + { + if (lod == null || lod.renderer == null || regenBackupMeshes == null) return; + int rid = lod.renderer.GetInstanceID(); + if (!regenBackupMeshes.TryGetValue(rid, out var backup) || backup == null) return; + var mf = lod.renderer.GetComponent(); + if (mf == null) return; + Undo.RecordObject(mf, "Discard Regenerate"); + mf.sharedMesh = backup; + regenBackupMeshes.Remove(rid); + freshRendererIds?.Remove(rid); + buildIntent |= FbxExportIntent.AnyUv | FbxExportIntent.Normals + | FbxExportIntent.Tangents | FbxExportIntent.VertexColors; + UvtLog.Info($"[LightmapUV] Discarded regenerate on '{lod.renderer.name}' — restored {backup.name}."); + ctx.LodGroup.RecalculateBounds(); + ctx.Refresh(ctx.LodGroup); + hierarchyDummies = null; + requestRepaint?.Invoke(); + } + + // ── Pre-Apply staleness detection ── + // After Insert / Delete / Regenerate the LOD slot indices and the + // renderer GameObject names drift apart (e.g. inserting a slot at + // index 1 leaves the old `_LOD1` GameObject sitting in slot 2). Apply + // Names rebuilds the canonical names; until then the row is "stale". + + bool IsLodRowStale(HierarchyDummy dummy, HierarchyLodRow lod) + { + if (dummy == null || lod == null || lod.renderer == null || ctx.LodGroup == null) + return false; + string current = lod.renderer.gameObject.name; + string baseName = UvToolContext.ExtractGroupKey(current); + if (string.IsNullOrEmpty(baseName)) + baseName = dummy.isRoot ? ctx.LodGroup.gameObject.name : dummy.dummy.name; + return current != $"{baseName}_LOD{lod.lodIndex}"; + } + + bool IsColRowStale(HierarchyDummy dummy, HierarchyColRow col, int index, int totalCols) + { + if (dummy == null || col == null || col.colTransform == null || ctx.LodGroup == null) + return false; + // Component-only rows live ON the dummy/root and can't be renamed + // independently — never report them as stale. + if (col.isComponentOnly) return false; + // Compute base from the existing name so custom prefixes + // (e.g. "Stove_Base_COL") are preserved through Rebuild. + string current = col.colTransform.gameObject.name; + string baseName = UvToolContext.ExtractGroupKey(current); + if (string.IsNullOrEmpty(baseName)) + baseName = dummy.isRoot ? ctx.LodGroup.gameObject.name : dummy.dummy.name; + // Count standalone _COL siblings to choose single vs Hull{N} convention. + int standaloneCount = 0; + int hullIdx = -1; + int hullPos = 0; + foreach (var c in dummy.cols) + { + if (c.isComponentOnly) continue; + if (ReferenceEquals(c, col)) hullIdx = hullPos; + hullPos++; + standaloneCount++; + } + string expected = standaloneCount <= 1 + ? $"{baseName}_COL" + : $"{baseName}_COL_Hull{hullIdx}"; + return current != expected; + } + + bool DummyHasStale(HierarchyDummy dummy) + { + if (dummy == null) return false; + foreach (var lod in dummy.lods) + if (IsLodRowStale(dummy, lod)) return true; + for (int i = 0; i < dummy.cols.Count; i++) + if (IsColRowStale(dummy, dummy.cols[i], i, dummy.cols.Count)) return true; + return false; + } - int regenerated = 0; - try + // ── Default slider ratio from polygon counts ── + // Compute the row's quality slider default as currentTris / LOD0Tris + // within the same chain. LOD0 itself reads as 1.0. Falls back to a + // safe 1.0 when the source LOD0 isn't readable (e.g. non-readable + // FBX import — Mesh.GetIndexCount works regardless of isReadable). + static float ComputeLodRatioFromTriangles(HierarchyDummy dummy, HierarchyLodRow lod) + { + if (lod == null || lod.mesh == null) return 1f; + if (lod.lodIndex == 0) return 1f; + + string key = lod.renderer != null + ? UvToolContext.ExtractGroupKey(lod.renderer.name) : null; + int lod0Tris = 0; + foreach (var candidate in dummy.lods) { - foreach (var r in lodN) + if (candidate?.mesh == null) continue; + if (candidate.lodIndex != 0) continue; + if (key != null && candidate.renderer != null) { - if (r == null) continue; - var mf = r.GetComponent(); - if (mf == null) continue; - var key = UvToolContext.ExtractGroupKey(r.name); - if (string.IsNullOrEmpty(key)) continue; - if (!sourceByKey.TryGetValue(key, out var sourceMesh)) continue; - - var res = MeshSimplifier.Simplify(sourceMesh, settings); - if (!res.ok) - { - UvtLog.Warn($"[LOD] Simplify failed for '{sourceMesh.name}': {res.error}"); + string ck = UvToolContext.ExtractGroupKey(candidate.renderer.name); + if (!string.Equals(ck, key, System.StringComparison.OrdinalIgnoreCase)) continue; - } - res.simplifiedMesh.name = sourceMesh.name + "_LOD" + lodIndex; - - Undo.RecordObject(mf, "Regenerate LOD"); - mf.sharedMesh = res.simplifiedMesh; - regenerated++; } + lod0Tris = MeshHygieneUtility.GetTriangleCount(candidate.mesh); + break; } - finally - { - EditorUtility.ClearProgressBar(); - } + if (lod0Tris <= 0) return 1f; + int currentTris = MeshHygieneUtility.GetTriangleCount(lod.mesh); + return Mathf.Clamp(currentTris / (float)lod0Tris, 0.001f, 1f); + } - if (regenerated > 0) - { - UvtLog.Info($"[LOD] Regenerated LOD{lodIndex}: {regenerated} mesh(es), ratio={ratio:P0}"); - ctx.LodGroup.RecalculateBounds(); - ctx.Refresh(ctx.LodGroup); - requestRepaint?.Invoke(); - } - else - { - UvtLog.Warn($"[LOD] Regenerate LOD{lodIndex}: nothing matched (check that LODN renderer names share a base with LOD0)."); - } + // ── Short leaf name ── + // Strip the chain base from a leaf GameObject name so the row only + // shows the trailing suffix (e.g. "Foo_Bar_LOD0" → "_LOD0", + // "Foo_Bar_COL_Hull1" → "_COL_Hull1"). The base is already shown in + // the dummy / chain header above, so repeating it on every row was + // pure noise. Falls back to the full name when the leaf doesn't + // start with the canonical base or has no recognisable suffix. + static string ShortLeafName(string fullName) + { + if (string.IsNullOrEmpty(fullName)) return fullName; + string baseKey = UvToolContext.ExtractGroupKey(fullName); + if (string.IsNullOrEmpty(baseKey)) return fullName; + if (fullName.Length <= baseKey.Length) return fullName; + if (fullName.StartsWith(baseKey, System.StringComparison.OrdinalIgnoreCase)) + return fullName.Substring(baseKey.Length); + return fullName; } - void MoveRendererBetweenLods(Renderer renderer, int fromLod, int toLod) + // ── Channel badges: compact summary of which mesh streams have data. ── + // Uses Mesh.HasVertexAttribute (which works on non-readable FBX-imported + // meshes) instead of GetUVs/colors32/normals — the data accessors silently + // return empty arrays when isReadable is false, so the badges used to + // appear only after a regenerate (which produces a readable mesh) and + // never on the original import meshes. + + static string ChannelBadges(Mesh mesh) + { + if (mesh == null) return ""; + var parts = new List(); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.TexCoord0)) parts.Add("UV0"); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.TexCoord1)) parts.Add("UV1"); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.TexCoord2)) parts.Add("UV2"); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.TexCoord3)) parts.Add("UV3"); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.Color)) parts.Add("VC"); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.Normal)) parts.Add("N"); + if (mesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.Tangent)) parts.Add("T"); + return parts.Count == 0 ? "—" : string.Join("·", parts); + } + + // Helper kept for use by FixSplitByMaterial, which still needs to rebuild + // the LODGroup after restructuring renderer GameObjects. + void RebuildLodGroupFromNames() { if (ctx.LodGroup == null) return; - Undo.RecordObject(ctx.LodGroup, "Move Renderer LOD"); - var lods = ctx.LodGroup.GetLODs(); - if (fromLod < 0 || fromLod >= lods.Length || toLod < 0 || toLod >= lods.Length) return; + var root = ctx.LodGroup.transform; + var colSet = new HashSet(MeshHygieneUtility.FindCollisionObjects(root)); + var lodChildren = new SortedDictionary>(); + + foreach (var r in root.GetComponentsInChildren(true)) + { + if (r == null || r.transform == root) continue; + if (colSet.Contains(r.gameObject)) continue; + + var match = System.Text.RegularExpressions.Regex.Match( + r.gameObject.name, @"_LOD(\d+)$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!match.Success) continue; + + int lodIdx = int.Parse(match.Groups[1].Value); + if (!lodChildren.ContainsKey(lodIdx)) + lodChildren[lodIdx] = new List(); + lodChildren[lodIdx].Add(r); + } - // Remove from source LOD - var srcList = new List(lods[fromLod].renderers ?? new Renderer[0]); - srcList.Remove(renderer); - lods[fromLod].renderers = srcList.ToArray(); + if (lodChildren.Count == 0) return; - // Add to target LOD - var dstList = new List(lods[toLod].renderers ?? new Renderer[0]); - dstList.Add(renderer); - lods[toLod].renderers = dstList.ToArray(); + Undo.RecordObject(ctx.LodGroup, "Rebuild LODGroup"); - ctx.LodGroup.SetLODs(lods); - ctx.Refresh(ctx.LodGroup); - requestRepaint?.Invoke(); - UvtLog.Info($"Moved {renderer.name}: LOD{fromLod} -> LOD{toLod}"); + int lodCount = lodChildren.Count; + var newLods = new LOD[lodCount]; + int idx = 0; + foreach (var kvp in lodChildren) + { + float screenHeight = lodCount == 1 + ? 0.01f + : 1f - ((float)idx / (lodCount - 1)) * 0.99f; + newLods[idx] = new LOD(screenHeight, kvp.Value.ToArray()); + idx++; + } + ctx.LodGroup.SetLODs(newLods); + ctx.LodGroup.RecalculateBounds(); } // ═══════════════════════════════════════════════════════════ - // Collision management section + // Collision section (legacy — migrates to right-panel Collider Settings in PR-2) // ═══════════════════════════════════════════════════════════ void DrawCollisionSection() @@ -1419,7 +2491,6 @@ void DrawCollisionSection() var colObjects = MeshHygieneUtility.FindCollisionObjects(root); var rootCollider = root.GetComponent(); - // Current state if (rootCollider != null) { Mesh colMesh = rootCollider.sharedMesh; @@ -1443,7 +2514,6 @@ void DrawCollisionSection() EditorGUILayout.LabelField("Root: no MeshCollider", EditorStyles.miniLabel); } - // Collision child objects if (colObjects.Count > 0) { EditorGUILayout.Space(4); @@ -1463,7 +2533,6 @@ void DrawCollisionSection() EditorGUILayout.LabelField($"{mesh.vertexCount:N0}v", EditorStyles.miniLabel, GUILayout.Width(60)); - // Toggle renderer visibility if (mr != null) { bool vis = mr.enabled; @@ -1475,7 +2544,6 @@ void DrawCollisionSection() } } - // Assign to root button if (mesh != null && (rootCollider == null || rootCollider.sharedMesh != mesh)) { if (GUILayout.Button("Assign", GUILayout.Width(50), GUILayout.Height(16))) @@ -1486,12 +2554,10 @@ void DrawCollisionSection() } } - // Action buttons EditorGUILayout.Space(4); var bgc = GUI.backgroundColor; EditorGUILayout.BeginHorizontal(); - // Add MeshCollider from first _COL child if (rootCollider == null && colObjects.Count > 0) { GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f); @@ -1504,7 +2570,6 @@ void DrawCollisionSection() } } - // Use LOD0 mesh as collision (if no _COL exists) if (rootCollider == null && colObjects.Count == 0) { GUI.backgroundColor = new Color(0.6f, 0.75f, 0.9f); @@ -1520,7 +2585,6 @@ void DrawCollisionSection() } } - // Disable all COL renderers if (colObjects.Count > 0) { GUI.backgroundColor = new Color(0.7f, 0.4f, 0.95f); @@ -1560,7 +2624,7 @@ void AssignCollisionToRoot(Mesh mesh) if (mc == null) { mc = Undo.AddComponent(root); - UvtLog.Info($"Added MeshCollider to {root.name}"); + UvtLog.Info($"[LightmapUV] Added MeshCollider to {root.name}"); } else { @@ -1568,12 +2632,13 @@ void AssignCollisionToRoot(Mesh mesh) } mc.sharedMesh = mesh; - UvtLog.Info($"Assigned collision mesh: {mesh.name}"); + buildIntent |= FbxExportIntent.Collision; + UvtLog.Info($"[LightmapUV] Assigned collision mesh: {mesh.name}"); requestRepaint?.Invoke(); } // ═══════════════════════════════════════════════════════════ - // Split / Merge section + // Split / Merge section (legacy — migrates in PR-2) // ═══════════════════════════════════════════════════════════ void DrawSplitMergeSection() @@ -1588,7 +2653,6 @@ void DrawSplitMergeSection() ScanSplitMerge(); GUI.backgroundColor = bgc; - // ── Split by Material ── if (splitCandidates != null) { EditorGUILayout.Space(4); @@ -1616,7 +2680,6 @@ void DrawSplitMergeSection() EditorGUILayout.LabelField(info, EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); - // Preview names if (sc.include) { string srcName = sc.entry.renderer.name; @@ -1640,7 +2703,6 @@ void DrawSplitMergeSection() } } - // Split preview + apply buttons EditorGUILayout.BeginHorizontal(); int splitSel = 0; foreach (var s in splitCandidates) if (s.include) splitSel++; @@ -1664,7 +2726,6 @@ void DrawSplitMergeSection() } } - // ── Merge Same-Material ── if (mergeCandidates != null) { EditorGUILayout.Space(8); @@ -1761,7 +2822,7 @@ void ScanSplitMerge() if (kvp.Value.entries.Count > 1) mergeCandidates.Add(kvp.Value); - UvtLog.Info($"Split/Merge scan: {splitCandidates.Count} split, {mergeCandidates.Count} merge."); + UvtLog.Info($"[LightmapUV] Split/Merge scan: {splitCandidates.Count} split, {mergeCandidates.Count} merge."); } void FixSplitByMaterial() @@ -1798,7 +2859,6 @@ void FixSplitByMaterial() string matName = mats[s] != null ? mats[s].name : $"mat{s}"; string childName = $"{srcName}_{matName}{lodSuffix}"; - // Extract submesh var subTris = mesh.GetTriangles(s); var subMesh = MeshHygieneUtility.ExtractSubmesh(mesh, subTris); if (subMesh == null) continue; @@ -1820,7 +2880,7 @@ void FixSplitByMaterial() GameObjectUtility.GetStaticEditorFlags(sc.entry.renderer.gameObject)); } - UvtLog.Info($"Split: {sc.entry.renderer.name} -> {mesh.subMeshCount} children"); + UvtLog.Info($"[LightmapUV] Split: {sc.entry.renderer.name} → {mesh.subMeshCount} children"); Undo.DestroyObjectImmediate(sc.entry.renderer.gameObject); split++; } @@ -1832,12 +2892,15 @@ void FixSplitByMaterial() ctx.Refresh(ctx.LodGroup); RebuildLodGroupFromNames(); ctx.LodGroup.RecalculateBounds(); + buildIntent |= FbxExportIntent.Hierarchy | FbxExportIntent.LodGroup + | FbxExportIntent.Materials; } splitCandidates = null; mergeCandidates = null; preview?.Restore(); previewMode = PreviewMode.None; + hierarchyDummies = null; requestRepaint?.Invoke(); } @@ -1856,10 +2919,8 @@ void FixMerge() var firstEntry = g.entries[0]; if (firstEntry.renderer == null) continue; - var parent = firstEntry.renderer.transform.parent; var baseMatrix = firstEntry.renderer.transform.worldToLocalMatrix; - // Combine meshes var allPos = new List(); var allNormals = new List(); var allUvs = new List(); @@ -1906,7 +2967,6 @@ void FixMerge() Undo.RecordObject(firstEntry.meshFilter, "Merge"); firstEntry.meshFilter.sharedMesh = mergedMesh; - // Update LODGroup renderers var lods = ctx.LodGroup.GetLODs(); for (int li = 0; li < lods.Length; li++) { @@ -1937,7 +2997,7 @@ void FixMerge() } merged++; - UvtLog.Info($"Merged: {g.entries.Count} objects -> {firstEntry.renderer.name}"); + UvtLog.Info($"[LightmapUV] Merged: {g.entries.Count} objects → {firstEntry.renderer.name}"); } Undo.CollapseUndoOperations(undoGroup); @@ -1946,17 +3006,19 @@ void FixMerge() { ctx.Refresh(ctx.LodGroup); ctx.LodGroup.RecalculateBounds(); + buildIntent |= FbxExportIntent.Hierarchy | FbxExportIntent.LodGroup; } splitCandidates = null; mergeCandidates = null; preview?.Restore(); previewMode = PreviewMode.None; + hierarchyDummies = null; requestRepaint?.Invoke(); } // ═══════════════════════════════════════════════════════════ - // Mesh info section + // Mesh info / edge / problem report sections // ═══════════════════════════════════════════════════════════ void DrawMeshInfo() @@ -1987,10 +3049,6 @@ void DrawMeshInfo() } } - // ═══════════════════════════════════════════════════════════ - // Edge report - // ═══════════════════════════════════════════════════════════ - void BuildEdgeReports() { edgeReports = new List(); @@ -2035,10 +3093,6 @@ void DrawEdgeReportSection() } } - // ═══════════════════════════════════════════════════════════ - // Problem summary - // ═══════════════════════════════════════════════════════════ - void BuildProblemSummaries() { problemSummaries = new List(); @@ -2094,10 +3148,6 @@ void DrawProblemSummarySection() } } - // ═══════════════════════════════════════════════════════════ - // Edge legend (shown when Edge mode is active) - // ═══════════════════════════════════════════════════════════ - void DrawEdgeLegend() { if (previewMode != PreviewMode.EdgeWireframe && previewMode != PreviewMode.ProblemAreas) diff --git a/Editor/Tools/VertexColorBakingTool.cs b/Editor/Tools/VertexColorBakingTool.cs index 4738ac0e..a10c46f7 100644 --- a/Editor/Tools/VertexColorBakingTool.cs +++ b/Editor/Tools/VertexColorBakingTool.cs @@ -68,6 +68,7 @@ public class VertexColorBakingTool : IUvTool // Bake settings bool backfaceCulling = true; bool cosineWeighted = true; + bool binaryHit = false; int bakeMode = 0; // 0=GPU, 1=CPU int bakeTypeIndex = 0; // 0=AO, 1=Thickness int occluderModeIndex = (int)VertexAOOccluderMode.SameRootNearby; @@ -522,6 +523,9 @@ void DrawBakeSettings() cosineWeighted = EditorGUILayout.Toggle( new GUIContent("Cosine Weighted", "Cosine: rays near normal contribute more (physically correct).\nUniform: all hemisphere directions contribute equally (harder shadows)."), cosineWeighted); + binaryHit = EditorGUILayout.Toggle( + new GUIContent("Binary Hit", "On: any ray hit fully occludes regardless of distance — preserves dark corners and tight crevices (Fewes-style).\nOff: linear distance falloff (1 − t/Radius) — softer, more global."), + binaryHit); if (bakeTypeIndex == 0) { @@ -1095,6 +1099,7 @@ void ExecuteBake() groundOffset = groundOffset, backfaceCulling = backfaceCulling, cosineWeighted = cosineWeighted, + binaryHit = binaryHit, useGPU = bakeMode == 0, bakeType = (AOBakeType)bakeTypeIndex, occluderMode = bakeTypeIndex == 0 diff --git a/Editor/Uv0Analyzer.cs b/Editor/Uv0Analyzer.cs index 061abe1a..d77b204d 100644 --- a/Editor/Uv0Analyzer.cs +++ b/Editor/Uv0Analyzer.cs @@ -289,6 +289,8 @@ public static Mesh WeldUv0(Mesh source) UvtLog.Verbose($"[UV0Fix] '{source.name}': welded {weldMap.Count} pairs, " + $"removed {removed} vertices ({vertCount} → {newVertCount})"); + TangentValidator.ValidateAfterWeld(source, result, "WeldUv0"); + return result; } @@ -308,6 +310,10 @@ public static bool WeldInPlace(Mesh mesh) if (uv0 == null || uv0.Length == 0) return false; + // Snapshot tangent presence before the in-place rebuild so the post-weld + // validation can detect TBN data going missing during the merge. + bool hadTangentsBefore = TangentValidator.HasTangents(mesh); + bool hasNormals = normals != null && normals.Length == vertCount; var weldMap = BuildWeldMap(verts, uv0, normals, hasNormals); if (weldMap.Count == 0) return false; @@ -410,6 +416,13 @@ public static bool WeldInPlace(Mesh mesh) UvtLog.Verbose($"[UV0Fix] WeldInPlace '{mesh.name}': " + $"welded {weldMap.Count} pairs, {vertCount} → {newVertCount} verts"); + + bool nowHasTangents = TangentValidator.HasTangents(mesh); + if (hadTangentsBefore && !nowHasTangents) + UvtLog.Warn($"[TBN] WeldInPlace '{mesh.name}': source had tangents but result has none — TBN dropped during weld"); + else if (nowHasTangents) + TangentValidator.ValidateTangentsW(mesh.tangents, mesh.name, "WeldInPlace"); + return true; } @@ -674,6 +687,8 @@ public static Mesh SourceGuidedWeld(Mesh target, Mesh source) $"removed {removed} verts ({tVertCount} → {newVertCount}), " + $"shells {shellsBefore} → {shellsAfter}"); + TangentValidator.ValidateAfterWeld(target, result, "SourceGuidedWeld"); + return result; } @@ -1014,9 +1029,62 @@ public static Mesh UvEdgeWeld(Mesh mesh, float uvThreshold = 0.002f) $"({vertCount} → {newVertCount}), " + $"shells {shellsBefore} → {shellsAfter}"); + // After the merge, run TBN sanity. UvEdgeWeld writes through Union-Find + // groups so different merged vertices may have had opposing tangent.w — + // this is exactly the case the user asked us to surface as an error. + TangentValidator.ValidateAfterWeld(mesh, result, "UvEdgeWeld"); + ValidateUnionFindTangentHandedness(mesh, parent, "UvEdgeWeld"); + return result; } + // ═══════════════════════════════════════════════════════════ + // Tangent handedness check across Union-Find weld groups. + // When two vertices with opposing tangent.w are unified the + // resulting normal-mapped lighting will flip on one side, so + // warn loudly when the input mesh had tangents. + // ═══════════════════════════════════════════════════════════ + static void ValidateUnionFindTangentHandedness(Mesh sourceMesh, int[] parent, string operation) + { + if (sourceMesh == null || parent == null) return; + var srcTan = sourceMesh.tangents; + if (srcTan == null || srcTan.Length != parent.Length) return; + + var rootSign = new Dictionary(); + int conflictVerts = 0; + int firstConflictRoot = -1; + + for (int i = 0; i < parent.Length; i++) + { + int root = Find(parent, i); + if (root == i) continue; + + float w = srcTan[i].w; + if (w == 0f || float.IsNaN(w)) continue; + int sign = w > 0f ? 1 : -1; + + if (!rootSign.TryGetValue(root, out int existing)) + { + float rootW = srcTan[root].w; + if (rootW != 0f && !float.IsNaN(rootW)) + rootSign[root] = rootW > 0f ? 1 : -1; + else + rootSign[root] = sign; + existing = rootSign[root]; + } + + if (existing != sign) + { + conflictVerts++; + if (firstConflictRoot < 0) firstConflictRoot = root; + } + } + + if (conflictVerts > 0) + UvtLog.Warn($"[TBN] {operation} '{sourceMesh.name}': merged {conflictVerts} vertices with opposing tangent.w handedness " + + $"(first conflict at root vertex {firstConflictRoot}) — normal map shading may flip across the merged seam"); + } + static void AddEdge(Dictionary> edgeMap, int[] posGroup, int i0, int i1) { diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index 600b895c..e79c45ca 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -143,6 +143,20 @@ internal static bool PrepareImportSettings(string assetPath, bool force = false, if (!peek) modelImporter.globalScale = 1f; changed = true; } + // Force MikkTSpace tangent recompute on every (re-)import. Unity's + // FBX Exporter doesn't preserve the Vector4-tangent W (handedness) + // bit reliably across roundtrips: FBX stores Tangent + Bitangent + // separately, and importTangents=Import would derive W from that + // pair, which can flip on mirrored UVs. CalculateMikk recomputes + // tangents+W from positions+normals+UV0 each time, deterministic + // across export/import cycles regardless of what the FBX layer + // chose to save. + if (modelImporter.importTangents != ModelImporterTangents.CalculateMikk) + { + if (!peek) UvtLog.Info($"[UV2 Preprocess] Set importTangents=CalculateMikk (was {modelImporter.importTangents}) on '{assetPath}'"); + if (!peek) modelImporter.importTangents = ModelImporterTangents.CalculateMikk; + changed = true; + } } if (modelImporter.meshCompression != ModelImporterMeshCompression.Off) { diff --git a/Editor/UvPngWriter.cs b/Editor/UvPngWriter.cs new file mode 100644 index 00000000..2b558328 --- /dev/null +++ b/Editor/UvPngWriter.cs @@ -0,0 +1,143 @@ +// UvPngWriter.cs — shared helper that renders a UV channel into a PNG via +// RenderTexture + Hidden/Internal-Colored. Used by FbxMetricsExporter for +// source-FBX baselines and by BenchmarkRecorder for per-cell result dumps. + +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class UvPngWriter + { + public const int DefaultSize = 1024; + const float UvLo = -0.1f, UvHi = 1.1f; // show OOB verts around the 0-1 box + + static readonly Color[] Palette = + { + new Color(0.9f, 0.3f, 0.3f), new Color(0.3f, 0.8f, 0.4f), + new Color(0.3f, 0.6f, 0.95f), new Color(0.95f, 0.75f, 0.2f), + new Color(0.8f, 0.4f, 0.9f), new Color(0.3f, 0.9f, 0.85f), + new Color(0.95f, 0.55f, 0.2f), new Color(0.6f, 0.8f, 0.2f), + }; + + static Material s_mat; + static Material GetMat() + { + if (s_mat != null) return s_mat; + var shader = Shader.Find("Hidden/Internal-Colored"); + if (shader == null) return null; + s_mat = new Material(shader) { hideFlags = HideFlags.HideAndDontSave }; + s_mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + s_mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + s_mat.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Off); + s_mat.SetInt("_ZWrite", 0); + return s_mat; + } + + /// + /// Write a PNG snapshot of + . + /// Triangles are filled per-shell (palette), edges drawn on top, 0-1 box in yellow. + /// The view covers [-0.1, 1.1] so out-of-bounds verts are visible. + /// + public static bool Render(string path, Vector2[] uv, int[] tris, int size = DefaultSize) + { + if (string.IsNullOrEmpty(path) || uv == null || tris == null || tris.Length < 3) return false; + var mat = GetMat(); + if (mat == null) return false; + + // Shell coloring is stable across multiple dumps of the same mesh. + int[] faceToShell = null; + try + { + var shells = UvShellExtractor.Extract(uv, tris); + faceToShell = new int[tris.Length / 3]; + foreach (var sh in shells) + foreach (var fi in sh.faceIndices) + if (fi >= 0 && fi < faceToShell.Length) faceToShell[fi] = sh.shellId; + } + catch { } + + var rt = RenderTexture.GetTemporary(size, size, 0, RenderTextureFormat.ARGB32); + var prev = RenderTexture.active; + RenderTexture.active = rt; + try + { + GL.Clear(true, true, new Color(0.08f, 0.08f, 0.09f, 1f)); + mat.SetPass(0); + + GL.PushMatrix(); + GL.LoadPixelMatrix(0, size, 0, size); + + // Fill triangles + GL.Begin(GL.TRIANGLES); + int fN = tris.Length / 3; + for (int f = 0; f < fN; f++) + { + int a = tris[f * 3], b = tris[f * 3 + 1], c = tris[f * 3 + 2]; + if (a >= uv.Length || b >= uv.Length || c >= uv.Length) continue; + int sid = faceToShell != null ? faceToShell[f] : 0; + var col = Palette[Mathf.Abs(sid) % Palette.Length]; + col.a = 0.5f; + GL.Color(col); + Vert(uv[a], size); Vert(uv[b], size); Vert(uv[c], size); + } + GL.End(); + + // Wire + GL.Begin(GL.LINES); + GL.Color(new Color(1, 1, 1, 0.25f)); + for (int f = 0; f < fN; f++) + { + int a = tris[f * 3], b = tris[f * 3 + 1], c = tris[f * 3 + 2]; + if (a >= uv.Length || b >= uv.Length || c >= uv.Length) continue; + Vert(uv[a], size); Vert(uv[b], size); + Vert(uv[b], size); Vert(uv[c], size); + Vert(uv[c], size); Vert(uv[a], size); + } + GL.End(); + + // 0-1 box + GL.Begin(GL.LINES); + GL.Color(new Color(1, 1, 0, 1)); + Vert(new Vector2(0, 0), size); Vert(new Vector2(1, 0), size); + Vert(new Vector2(1, 0), size); Vert(new Vector2(1, 1), size); + Vert(new Vector2(1, 1), size); Vert(new Vector2(0, 1), size); + Vert(new Vector2(0, 1), size); Vert(new Vector2(0, 0), size); + GL.End(); + + GL.PopMatrix(); + + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + tex.ReadPixels(new Rect(0, 0, size, size), 0, 0, false); + tex.Apply(); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + File.WriteAllBytes(path, tex.EncodeToPNG()); + Object.DestroyImmediate(tex); + } + finally + { + RenderTexture.active = prev; + RenderTexture.ReleaseTemporary(rt); + } + return true; + } + + /// Convenience: pull UV channel from mesh and call Render. + public static bool Render(string path, Mesh mesh, int uvChannel, int size = DefaultSize) + { + if (mesh == null) return false; + var list = new List(); + mesh.GetUVs(uvChannel, list); + if (list.Count == 0) return false; + return Render(path, list.ToArray(), mesh.triangles, size); + } + + static void Vert(Vector2 uv, int size) + { + float u = (uv.x - UvLo) / (UvHi - UvLo); + float v = (uv.y - UvLo) / (UvHi - UvLo); + GL.Vertex3(u * size, v * size, 0); + } + } +} diff --git a/Editor/UvPngWriter.cs.meta b/Editor/UvPngWriter.cs.meta new file mode 100644 index 00000000..fbd1f456 --- /dev/null +++ b/Editor/UvPngWriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a9005b90fd643c8bc9f9889acd1a878 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/UvtLog.cs b/Editor/UvtLog.cs index fb3661c7..e45af3b8 100644 --- a/Editor/UvtLog.cs +++ b/Editor/UvtLog.cs @@ -4,55 +4,112 @@ namespace SashaRX.UnityMeshLab { /// - /// Centralized logger with verbosity levels. + /// Centralized logger with verbosity levels and per-category mute mask. /// Stored in EditorPrefs per-user. /// public static class UvtLog { public enum Level { Off = 0, Error = 1, Warning = 2, Info = 3, Verbose = 4 } - const string PrefKey = "LightmapUvTool_LogLevel"; - const string Prefix = "[LightmapUV] "; + // Categories map to subsystems of the Lightmap UV pipeline. Bit index = (int)Category. + [System.Flags] + public enum Category + { + General = 1 << 0, + SymSplit = 1 << 1, + Repack = 1 << 2, + Match = 1 << 3, + Dedup = 1 << 4, + Overlap = 1 << 5, + Topology = 1 << 6, + Validation = 1 << 7, + Export = 1 << 8, + Benchmark = 1 << 9, + + All = General | SymSplit | Repack | Match | Dedup | Overlap | Topology | Validation | Export | Benchmark, + } - static Level? _cached; + const string LevelPrefKey = "LightmapUvTool_LogLevel"; + const string MaskPrefKey = "LightmapUvTool_LogCategoryMask"; + const string Prefix = "[LightmapUV]"; + + static Level? _cachedLevel; + static int? _cachedMask; public static Level Current { get { - if (!_cached.HasValue) - _cached = (Level)EditorPrefs.GetInt(PrefKey, (int)Level.Info); - return _cached.Value; + if (!_cachedLevel.HasValue) + _cachedLevel = (Level)EditorPrefs.GetInt(LevelPrefKey, (int)Level.Info); + return _cachedLevel.Value; } set { - _cached = value; - EditorPrefs.SetInt(PrefKey, (int)value); + _cachedLevel = value; + EditorPrefs.SetInt(LevelPrefKey, (int)value); } } - public static void Error(string msg) + /// Bitmask of enabled categories. Disabled categories are silenced regardless of Level. + public static Category EnabledCategories { - if (Current >= Level.Error) - Debug.LogError(Prefix + msg); + get + { + if (!_cachedMask.HasValue) + _cachedMask = EditorPrefs.GetInt(MaskPrefKey, (int)Category.All); + return (Category)_cachedMask.Value; + } + set + { + _cachedMask = (int)value; + EditorPrefs.SetInt(MaskPrefKey, (int)value); + } } - public static void Warn(string msg) + public static bool IsCategoryEnabled(Category c) => (EnabledCategories & c) != 0; + + public static void SetCategoryEnabled(Category c, bool enabled) { - if (Current >= Level.Warning) - Debug.LogWarning(Prefix + msg); + var mask = EnabledCategories; + if (enabled) mask |= c; + else mask &= ~c; + EnabledCategories = mask; } - public static void Info(string msg) + static string Tag(Category c) => $"{Prefix}[{c}] "; + + // ── Category-tagged overloads ── + + public static void Error(Category c, string msg) + { + if (Current >= Level.Error && IsCategoryEnabled(c)) + Debug.LogError(Tag(c) + msg); + } + + public static void Warn(Category c, string msg) { - if (Current >= Level.Info) - Debug.Log(Prefix + msg); + if (Current >= Level.Warning && IsCategoryEnabled(c)) + Debug.LogWarning(Tag(c) + msg); } - public static void Verbose(string msg) + public static void Info(Category c, string msg) { - if (Current >= Level.Verbose) - Debug.Log(Prefix + msg); + if (Current >= Level.Info && IsCategoryEnabled(c)) + Debug.Log(Tag(c) + msg); } + + public static void Verbose(Category c, string msg) + { + if (Current >= Level.Verbose && IsCategoryEnabled(c)) + Debug.Log(Tag(c) + msg); + } + + // ── Legacy overloads (default to Category.General) ── + + public static void Error (string msg) => Error (Category.General, msg); + public static void Warn (string msg) => Warn (Category.General, msg); + public static void Info (string msg) => Info (Category.General, msg); + public static void Verbose(string msg) => Verbose(Category.General, msg); } } diff --git a/Editor/VertexAOBaker.Cpu.cs b/Editor/VertexAOBaker.Cpu.cs index 07d9d594..9d74c8a4 100644 --- a/Editor/VertexAOBaker.Cpu.cs +++ b/Editor/VertexAOBaker.Cpu.cs @@ -103,6 +103,7 @@ static Dictionary BakeMultiMeshCPU( float jCos = Mathf.Cos(jitterAngle), jSin = Mathf.Sin(jitterAngle); bool cosW = settings.cosineWeighted; + bool binaryHit = settings.binaryHit; float occludedWeight = 0f, totalWeight = 0f; for (int d = 0; d < directions.Length; d++) { @@ -132,8 +133,9 @@ static Dictionary BakeMultiMeshCPU( } else { - // Distance falloff: closer hits occlude more - float falloff = 1f - hit.t / maxDist; + // Binary: any hit fully occludes (Fewes-style, harder corners). + // Falloff: closer hits occlude more (default, softer). + float falloff = binaryHit ? 1f : (1f - hit.t / maxDist); occludedWeight += weight * falloff; continue; } @@ -145,7 +147,7 @@ static Dictionary BakeMultiMeshCPU( float t = (groundY - origin.y) / jitteredDir.y; if (t > 0 && t < maxDist) { - float falloff = 1f - t / maxDist; + float falloff = binaryHit ? 1f : (1f - t / maxDist); occludedWeight += weight * falloff; } } diff --git a/Editor/VertexAOBaker.Gpu.cs b/Editor/VertexAOBaker.Gpu.cs index 933e1808..91bf8ad5 100644 --- a/Editor/VertexAOBaker.Gpu.cs +++ b/Editor/VertexAOBaker.Gpu.cs @@ -267,6 +267,7 @@ void Prepare( cs.SetFloat("_NormalOffset", normalOffset); cs.SetFloat("_MinHitDist", minHitDist); cs.SetFloat("_CosineWeighted", settings.cosineWeighted ? 1f : 0f); + cs.SetFloat("_BinaryHit", settings.binaryHit ? 1f : 0f); cs.SetFloat("_FlipNormals", isThickness ? 1f : 0f); cs.SetFloat("_BackfaceCulling", settings.backfaceCulling ? 1f : 0f); cs.SetFloat("_GroundPlane", (settings.groundPlane && !isThickness) ? 1f : 0f); diff --git a/Editor/VertexAOBaker.cs b/Editor/VertexAOBaker.cs index 440f8ba3..9abb3646 100644 --- a/Editor/VertexAOBaker.cs +++ b/Editor/VertexAOBaker.cs @@ -42,6 +42,7 @@ public class VertexAOSettings public bool backfaceCulling = true; public bool useGPU = true; public bool cosineWeighted = true; + public bool binaryHit = false; public AOBakeType bakeType = AOBakeType.AmbientOcclusion; public VertexAOOccluderMode occluderMode = VertexAOOccluderMode.SameRootNearby; public float occluderRadiusMultiplier = 2.0f; diff --git a/Editor/XatlasNative.cs b/Editor/XatlasNative.cs index f1b77a53..c9fb6813 100644 --- a/Editor/XatlasNative.cs +++ b/Editor/XatlasNative.cs @@ -32,7 +32,9 @@ public static class XatlasNative uint resolution, int bilinear, int blockAlign, - int bruteForce); + int bruteForce, + int rotateCharts, + int rotateChartsToAxis); // ── Queries ── [DllImport(DLL)] public static extern int xatlasGetMeshCount(); diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index e06cfa3f..4d59fc74 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -1,7 +1,10 @@ // XatlasRepack.cs — High-level xatlas repack with C#-side UV2 write-back // Place in Assets/Editor/ +using System; using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEditor; using UnityEngine; namespace SashaRX.UnityMeshLab @@ -12,9 +15,119 @@ public struct RepackOptions public uint borderPadding; // atlas edge padding (pixels), default 0 public uint resolution; public float texelsPerUnit; + /// Max chart dimension in pixels. 0 = no limit; xatlas may emit a + /// chart larger than the atlas which then forces atlas growth. Set to a value + /// ≤ resolution (e.g. resolution/2 or resolution) to keep individual charts + /// inside the requested atlas size. + public int maxChartSize; public bool bilinear; public bool blockAlign; + /// + /// Compression block size in texels for blockAlign. xatlas snaps charts + /// to a hard-coded 4×4 grid (correct for BC1/BC3/BC5/BC7/ETC2/DXT*). + /// For ASTC the block is variable (4, 5, 6, 8, 10, 12). The field is + /// surfaced here and in the UI so projects can declare their target + /// alignment; the actual post-pack snap to non-4 grids is not wired + /// yet — at default 4 behaviour matches xatlas exactly, and for >4 a + /// follow-up will add per-chart snapping after pack. Tracked as TODO. + /// + public int blockSize; public bool bruteForce; + /// xatlas may rotate charts to fit better during packing (default true). + public bool rotateCharts; + /// Constrain chart rotation to axis-aligned 90° increments (default true). + public bool rotateChartsToAxis; + /// + /// Per-group-member UV0 scale offset used to break xatlas's chart + /// dedup on tile-instance shells (so each tile occupies a unique + /// atlas slot — required for lightmap UV2 uniqueness). + /// 0 = adaptive: derive from atlas resolution + padding. + /// >0 = manual override, where N means each subsequent shell in an + /// overlap-group is scaled around the rep's centroid by 1 + (i*N). + /// Typical adaptive output: 0.01..0.03. Larger values handle more + /// aggressive xatlas dedup at the cost of texel-density variance + /// within the group. + /// + public float perturbStrength; + + /// + /// Pre-pack pass that rescales each shell's UV0 so its UV-area is + /// proportional to its 3D surface area (uniform texels-per-world-unit + /// in the final lightmap). Without this, authored UV0 with mixed + /// scales propagates straight to UV2 and the baked lightmap has + /// detail varying by 10x+ across the model. Default ON for lightmap + /// use. Disable only when preserving an existing baked-texture UV + /// layout that already encodes desired non-uniform density. + /// + public bool normalizeTexelDensity; + + /// + /// Auto-reparameterize shells whose UV0 stretch (Sander L² metric) exceeds + /// . Replaces the previous IsRibbon-based + /// trigger — now driven by an actual UV quality metric. ARAP local-global + /// solver redistributes vertices to minimize per-triangle isometric + /// distortion. + /// + public bool reparameterizeStretchedShells; + + /// + /// Sander L² stretch above which a shell triggers ARAP re-parameterization. + /// 1.0 = isometric; 1.5 = mild; 2.0 = noticeably stretched; 3.0+ = severe. + /// Default 1.5. + /// + public float stretchThreshold; + + /// + /// ARAP local-global iteration count for stretched-shell reparameterization. + /// 50 is the default; raise to 100-200 for highly curved/twisted strips. + /// + public int arapIterations; + + /// + /// Clamp the source mesh's UV2 channel into [0,1] right after xatlas + /// writes the atlas. Cheap safety net against verts pushed slightly + /// outside the unit square by border padding or perturb fixups. + /// Default true. + /// + public bool clampLightmapToUnit; + + /// + /// Post-pack density correction (experimental). xatlas internally applies + /// per-chart ceil(extents) stretch which breaks uniform density for thin + /// shells. This pass measures per-shell au2/a3 after pack and SHRINKS + /// over-dense shells around their UV2 centroid to match the median. + /// Shrink-only (never expands) so neighbours can't collide. Default false. + /// + public bool postPackDensityCorrection; + + /// + /// Internal xatlas atlas oversampling factor. xatlas applies an + /// unconditional per-chart ceil(extents.x/y) stretch (xatlas.cpp:8345- + /// 8362) that amplifies thin shells (extent < 1 atlas-pixel) by up to + /// 20×. Running the internal pack at a higher resolution makes every + /// chart's pixel extents N× larger, so ceil() rounding becomes a + /// fraction of a percent instead of multiples. Output UV2 is + /// normalized to [0,1] regardless of internal atlas size so the user- + /// facing resolution parameter still controls UV layout precision and + /// the final lightmap baked by Unity is unaffected. Padding (both + /// shell and border) is scaled by the same factor so the gap fraction + /// in UV space stays constant. Default 4 — at 256×256 user-facing + /// resolution the internal pack runs at 1024×1024 which brings + /// per-shell density spread from ~14× down to ~1.1×. + /// + public int internalOversample; + + /// + /// Fraction of [0,1]² atlas that normalized UVs should sum to AFTER + /// per-shell density equalisation. Bin-packing leaves slack, so + /// total chart area below 1.0 keeps xatlas from overflowing the + /// requested resolution and downscaling. Default 0.75 — 25% safety + /// margin matching typical bruteForce packing efficiency on + /// mixed-aspect chart sets. 0 disables the budget step (preserves + /// original total UV area). Only used when normalizeTexelDensity + /// is true. + /// + public float targetUvCoverage; public static RepackOptions Default => new RepackOptions { @@ -22,9 +135,34 @@ public struct RepackOptions borderPadding = 0, resolution = 0, texelsPerUnit = 0f, + maxChartSize = 0, // 0 = unbounded bilinear = true, blockAlign = false, - bruteForce = false, + blockSize = 4, // BC/ETC/DXT default; ASTC: 4/5/6/8/10/12 + bruteForce = true, + rotateCharts = true, + // PCA-align per chart rotates the bbox before extents are + // measured. For repack of existing UVs (UvMesh input) it's + // an extra mutation that shrinks pixel extents and worsens + // Stage B ceil() amplification on thin shells. Keep off by + // default; rotateCharts (90° placement rotation in the + // packer) stays on for pack efficiency. + rotateChartsToAxis = false, + perturbStrength = 0f, // adaptive + normalizeTexelDensity = true, + targetUvCoverage = 0.75f, + reparameterizeStretchedShells = true, + stretchThreshold = 1.5f, + arapIterations = 50, + clampLightmapToUnit = true, + postPackDensityCorrection = false, + // Pack at 4× user-facing resolution internally. xatlas's + // per-chart ceil(extents) Stage B amplifies sub-pixel + // shells; running the pack at 4× resolution turns a shell + // that was 0.25 px wide (4× amp) into 1.0 px wide (1× + // amp). Output UVs are normalised to [0,1] via /atlasW so + // the effective user atlas stays at opts.resolution. + internalOversample = 4, }; } @@ -47,6 +185,8 @@ public struct RepackResult public static class XatlasRepack { const uint ORPHAN_CHART = uint.MaxValue; + const long kBruteCostBudget = 500_000_000L; // ~5-10s wall + const long kHeuristicCostBudget = 20_000_000_000L; // ~30-60s wall /// /// Flip UV0 shells with negative signed area (mirrored) so all charts @@ -92,25 +232,57 @@ public static int NormalizeShellWinding(Vector2[] uv0, int[] tris, List /// packing identical SymSplit halves at the same atlas position. /// Operates on the flat UV0 copy — does NOT modify the original mesh. /// + /// + /// Adaptive default for tile-instance UV0 perturbation strength. + /// Goal: each subsequent shell in an overlap-group needs to differ + /// from the previous one by at least a few atlas pixels after + /// pixel-quantization, otherwise xatlas's bin-packer dedups them + /// onto the same atlas slot (which collapses lightmap UV2). Smaller + /// atlases quantize coarser → need bigger perturbation. Padding + /// scales the floor because xatlas absorbs sub-padding differences. + /// Output clamped to [0.01, 0.05] — 1% is enough at high resolution, + /// 5% is the largest we accept before texel density variance becomes + /// visible in baked lightmaps. + /// + internal static float ComputeAdaptivePerturbStrength(uint atlasResolution, uint padding) + { + const float MIN_STRENGTH = 0.01f; + const float MAX_STRENGTH = 0.05f; + if (atlasResolution == 0) return MIN_STRENGTH * 2f; // sensible fallback + // 4 padding-pixels of UV-space separation per group step keeps + // perturbed charts distinguishable through xatlas's quantization. + float padPixels = Mathf.Max(1f, padding); + float adaptive = 4f * padPixels / atlasResolution; + return Mathf.Clamp(adaptive, MIN_STRENGTH, MAX_STRENGTH); + } + internal static void PerturbOverlapShellsUv0( - float[] uvFlat, List shells, List> overlapGroups) + float[] uvFlat, List shells, List> overlapGroups, + float strength) { if (overlapGroups == null || overlapGroups.Count == 0) return; + if (strength <= 0f) return; - const float EPSILON_SCALE = 0.002f; // 0.2% per shell - + // Geometry is unaffected — uvFlat is a local copy fed to xatlas + // only; mesh.uv stays untouched. foreach (var group in overlapGroups) { if (group.Count < 2) continue; - // Compute centroid U of first shell to use as scale pivot + // Compute centroid (U,V) of first shell to use as scale pivot. + // Must scale BOTH axes uniformly — scaling U only flattens + // shells (fan-shaped discs become horizontal slivers when an + // N-fold symmetry group accumulates scale on each copy). + // Uniform scale only changes size, not shape; xatlas still + // sees them as distinct charts (different bbox dimensions). var firstShell = shells[group[0]]; float pivotU = (firstShell.boundsMin.x + firstShell.boundsMax.x) * 0.5f; + float pivotV = (firstShell.boundsMin.y + firstShell.boundsMax.y) * 0.5f; for (int g = 1; g < group.Count; g++) { - float scale = 1f + g * EPSILON_SCALE; + float scale = 1f + g * strength; var shell = shells[group[g]]; foreach (int vi in shell.vertexIndices) { @@ -118,385 +290,565 @@ internal static void PerturbOverlapShellsUv0( if ((uint)idx + 1 < (uint)uvFlat.Length) { float u = uvFlat[idx]; - uvFlat[idx] = pivotU + (u - pivotU) * scale; + float v = uvFlat[idx + 1]; + uvFlat[idx] = pivotU + (u - pivotU) * scale; + uvFlat[idx + 1] = pivotV + (v - pivotV) * scale; } } } } } + static float ComputeShellUvAreaAbs(Vector2[] uv, int[] tris, UvShell shell) + { + double area2 = 0.0; + foreach (int f in shell.faceIndices) + { + int i0 = tris[f * 3], i1 = tris[f * 3 + 1], i2 = tris[f * 3 + 2]; + if ((uint)i0 >= (uint)uv.Length || (uint)i1 >= (uint)uv.Length || (uint)i2 >= (uint)uv.Length) + continue; + var a = uv[i0]; + var b = uv[i1]; + var c = uv[i2]; + area2 += (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); + } + return Mathf.Abs((float)area2 * 0.5f); + } + /// - /// Post-repack: detect overlapping UV2 bounding boxes among shells that - /// shared UV0 space (overlap groups) and shift colliding shells apart. - /// If any UV2 exceeds [0,1] after shifts, rescales all UV2 to fit. - /// Returns number of shells shifted. + /// Run HardEdgeShellAnalyzer and emit a one-line summary + up to 10 + /// per-shell rows for shells that would split into ≥2 sub-components + /// with ≥2 faces each (user rule: don't cut if the outcome is just + /// unstitching one face from the rest). Pure logging — the result is + /// not yet applied to faceShellIds. Threshold 45° matches the + /// classic "smoothing-group / hard-edge" cutoff used by DCC tools. /// - internal static int FixOverlappingUv2Shells( - Vector2[] uv2, List shells, List> overlapGroups, - uint padding, uint atlasWidth, uint atlasHeight, - bool skipRescale = false) + static void LogHardEdgeAnalysis(List shells, int[] tris, Vector3[] positions, string meshLabel) { - if (overlapGroups == null || overlapGroups.Count == 0) - return 0; - - float padU = atlasWidth > 0 ? (float)padding / atlasWidth : 0f; - float padV = atlasHeight > 0 ? (float)padding / atlasHeight : 0f; - int shifted = 0; + var r = HardEdgeShellAnalyzer.Analyze(shells, tris, positions, angleThresholdDeg: 45f, minSubshellFaces: 2); + if (r.shellsSplittable == 0) + { + if (r.shellsWithHardEdges > 0) + UvtLog.Verbose(UvtLog.Category.Repack, + $"[HardEdgeAnalysis] '{meshLabel}': {r.shellsWithHardEdges}/{r.totalShellsAnalyzed} shells contain hard edges but none would split into ≥2 chunks — nothing to cut."); + return; + } + UvtLog.Info(UvtLog.Category.Repack, + $"[HardEdgeAnalysis] '{meshLabel}': {r.shellsSplittable}/{r.totalShellsAnalyzed} shell(s) would split into ≥2 sub-components on hard edges (45°). " + + $"({r.shellsWithHardEdges} shells contain at least one hard edge.)"); + int n = Mathf.Min(r.splittable.Count, 10); + for (int i = 0; i < n; i++) + { + var info = r.splittable[i]; + UvtLog.Info(UvtLog.Category.Repack, + $"[HardEdgeAnalysis] shell #{info.shellId}: {info.eligibleComponents} eligible sub-components (raw {info.totalComponents}, {info.hardEdgeCount} hard edge pairs)"); + } + if (r.splittable.Count > n) + UvtLog.Info(UvtLog.Category.Repack, + $"[HardEdgeAnalysis] …and {r.splittable.Count - n} more."); + } - foreach (var group in overlapGroups) + /// + /// Sum of signed UV2 triangle areas across the mesh. uv2 is in [0,1] + /// so the result equals the atlas utilization fraction (1.0 == 100% + /// of the atlas covered by charts; bin-packing typically lands at + /// 0.55–0.85 depending on chart shape mix and packer mode). + /// + internal static double ComputeUv2CoverageFraction(Vector2[] uv2, int[] tris) + { + if (uv2 == null || tris == null) return 0.0; + double sum = 0.0; + int triCount = tris.Length / 3; + int uvLen = uv2.Length; + for (int f = 0; f < triCount; f++) { - if (group.Count < 2) continue; + int t = f * 3; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)uvLen || (uint)i1 >= (uint)uvLen || (uint)i2 >= (uint)uvLen) continue; + Vector2 a = uv2[i0], b = uv2[i1], c = uv2[i2]; + sum += Math.Abs((double)(b.x - a.x) * (c.y - a.y) - (double)(c.x - a.x) * (b.y - a.y)) * 0.5; + } + return sum; + } - int gc = group.Count; - var mn = new Vector2[gc]; - var mx = new Vector2[gc]; - for (int i = 0; i < gc; i++) - { - mn[i] = new Vector2(float.MaxValue, float.MaxValue); - mx[i] = new Vector2(float.MinValue, float.MinValue); - var shell = shells[group[i]]; - foreach (int vi in shell.vertexIndices) - { - if ((uint)vi < (uint)uv2.Length) - { - mn[i] = Vector2.Min(mn[i], uv2[vi]); - mx[i] = Vector2.Max(mx[i], uv2[vi]); - } - } - } + /// + /// Per-shell post-xatlas UV2 density diagnostic. Groups output UV2 by + /// original UV0 shell (via shells[].faceIndices) and logs au2/a3 + /// distribution. If TexelDensityNormalizer made input shell densities + /// uniform but post-pack densities still vary, xatlas internally + /// rescaled charts — which means the upstream density correction was + /// thrown away by the pack step. + /// + static void LogPostPackDensity( + Vector2[] uv2, int[] tris, Vector3[] positions, + List shells, string meshLabel) + { + if (uv2 == null || tris == null || positions == null || shells == null) return; + int uvLen = uv2.Length; + int posLen = positions.Length; + int n = shells.Count; + if (n == 0) return; - for (int i = 0; i < gc; i++) + double dMin = double.MaxValue, dMax = 0.0, dSum = 0.0; + int dCount = 0; + double minRatio = double.MaxValue, maxRatio = 0.0; + int shellIdMin = -1, shellIdMax = -1; + for (int si = 0; si < n; si++) + { + var shell = shells[si]; + if (shell?.faceIndices == null) continue; + double a3 = 0.0, au = 0.0; + foreach (int f in shell.faceIndices) { - for (int j = i + 1; j < gc; j++) - { - float oMinX = Mathf.Max(mn[i].x, mn[j].x); - float oMinY = Mathf.Max(mn[i].y, mn[j].y); - float oMaxX = Mathf.Min(mx[i].x, mx[j].x); - float oMaxY = Mathf.Min(mx[i].y, mx[j].y); - if (oMaxX <= oMinX || oMaxY <= oMinY) continue; - - float overlapArea = (oMaxX - oMinX) * (oMaxY - oMinY); - float areaI = (mx[i].x - mn[i].x) * (mx[i].y - mn[i].y); - float areaJ = (mx[j].x - mn[j].x) * (mx[j].y - mn[j].y); - float smaller = Mathf.Min(areaI, areaJ); - if (smaller <= 0f || overlapArea / smaller < 0.01f) continue; - - float overlapRatio = overlapArea / smaller; - - // Choose shift axis: prefer the direction with less displacement - float shiftU = (mx[i].x - mn[j].x) + padU; - float shiftV = (mx[i].y - mn[j].y) + padV; - if (shiftU <= 0f) shiftU = padU; - if (shiftV <= 0f) shiftV = padV; - - var shell = shells[group[j]]; - string axisName; - float shiftMag; - if (shiftU <= shiftV) - { - foreach (int vi in shell.vertexIndices) - if ((uint)vi < (uint)uv2.Length) - uv2[vi] = new Vector2(uv2[vi].x + shiftU, uv2[vi].y); - mn[j] = new Vector2(mn[j].x + shiftU, mn[j].y); - mx[j] = new Vector2(mx[j].x + shiftU, mx[j].y); - axisName = "U"; - shiftMag = shiftU; - } - else - { - foreach (int vi in shell.vertexIndices) - if ((uint)vi < (uint)uv2.Length) - uv2[vi] = new Vector2(uv2[vi].x, uv2[vi].y + shiftV); - mn[j] = new Vector2(mn[j].x, mn[j].y + shiftV); - mx[j] = new Vector2(mx[j].x, mx[j].y + shiftV); - axisName = "V"; - shiftMag = shiftV; - } - UvtLog.Verbose($"[xatlas] Overlap fix: shell {group[i]}↔{group[j]} " + - $"ratio={overlapRatio:F3} shift={axisName}+{shiftMag:F4}"); - shifted++; - } + int t = f * 3; + if ((uint)(t + 2) >= (uint)tris.Length) continue; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)posLen || (uint)i1 >= (uint)posLen || (uint)i2 >= (uint)posLen) continue; + if ((uint)i0 >= (uint)uvLen || (uint)i1 >= (uint)uvLen || (uint)i2 >= (uint)uvLen) continue; + Vector3 p0 = positions[i0], p1 = positions[i1], p2 = positions[i2]; + a3 += Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5; + Vector2 a = uv2[i0], b = uv2[i1], c = uv2[i2]; + au += Math.Abs((double)(b.x - a.x) * (c.y - a.y) - (double)(c.x - a.x) * (b.y - a.y)) * 0.5; } + if (a3 < 1e-12 || au < 1e-12) continue; + double d = au / a3; + if (d < dMin) { dMin = d; shellIdMin = si; } + if (d > dMax) { dMax = d; shellIdMax = si; } + dSum += d; + dCount++; } - if (shifted > 0 && !skipRescale) - RescaleUv2ToUnit(uv2); + if (dCount == 0) return; + double dMean = dSum / dCount; + double ratio = (dMin > 1e-30) ? dMax / dMin : 0.0; - if (shifted > 0) - UvtLog.Info($"[xatlas] Post-repack: fixed {shifted} overlapping UV2 shell(s)"); + // Spread density across shells around the mean — a few outliers + // can swamp maxRatio, so also report how the bulk behaves. + int withinTenPct = 0; + for (int si = 0; si < n; si++) + { + var shell = shells[si]; + if (shell?.faceIndices == null) continue; + double a3 = 0.0, au = 0.0; + foreach (int f in shell.faceIndices) + { + int t = f * 3; + if ((uint)(t + 2) >= (uint)tris.Length) continue; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)posLen || (uint)i1 >= (uint)posLen || (uint)i2 >= (uint)posLen) continue; + if ((uint)i0 >= (uint)uvLen || (uint)i1 >= (uint)uvLen || (uint)i2 >= (uint)uvLen) continue; + Vector3 p0 = positions[i0], p1 = positions[i1], p2 = positions[i2]; + a3 += Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5; + Vector2 a = uv2[i0], b = uv2[i1], c = uv2[i2]; + au += Math.Abs((double)(b.x - a.x) * (c.y - a.y) - (double)(c.x - a.x) * (b.y - a.y)) * 0.5; + } + if (a3 < 1e-12 || au < 1e-12) continue; + double d = au / a3; + if (Math.Abs(d - dMean) <= 0.10 * dMean) withinTenPct++; + } - return shifted; + UvtLog.Info(UvtLog.Category.Repack, + $"[Density:postUV2] '{meshLabel}' shells={dCount} | au2/a3: min={dMin:G3}(shell#{shellIdMin}) max={dMax:G3}(shell#{shellIdMax}) mean={dMean:G3} maxRatio={ratio:F2}x | within±10%: {withinTenPct}/{dCount}"); } /// - /// Post-repack safety net: find shell pairs with nearly identical UV2 centroids - /// (true SymSplit duplicates packed at the same position) and fix their overlap. - /// Unlike the old global pass that checked ALL N² pairs (causing false positives - /// on dense atlases), this only checks pairs within centroid proximity threshold. + /// Shrink-only post-pack density correction. After xatlas packs charts + /// into the atlas its internal ceil(extents) stretch (xatlas.cpp:8345- + /// 8362) amplifies thin/anisotropic shells more than fat ones, breaking + /// the uniform density we set up in TexelDensityNormalizer. This pass + /// measures per-shell au2/a3 and shrinks shells whose density is above + /// the median toward it, keeping each shell anchored on its UV2 + /// centroid. Shrink only — never expand — so the layout stays valid + /// (shells can't collide into neighbours). The atlas ends up with some + /// gaps where shrunk shells used to be; this trades coverage for density + /// uniformity, which is the correct trade for lightmap bake quality. /// - internal static int FixNearDuplicateUv2Shells( - Vector2[] uv2, List shells, - uint padding, uint atlasWidth, uint atlasHeight, - bool skipRescale = false) + static int ApplyPostPackDensityCorrection( + Vector2[] uv2, int[] tris, Vector3[] positions, + List shells, string meshLabel) { - if (shells.Count < 2) return 0; - - float atlasDim = Mathf.Max(atlasWidth, atlasHeight); - if (atlasDim <= 0f) return 0; - - // Centroid proximity threshold: 4 pixels in UV space. - // True SymSplit duplicates are packed at essentially identical positions. - float centroidThreshold = 4f / atlasDim; - float centroidThresholdSq = centroidThreshold * centroidThreshold; + if (uv2 == null || tris == null || positions == null || shells == null) return 0; + int uvLen = uv2.Length; + int posLen = positions.Length; + int n = shells.Count; + if (n == 0) return 0; - // Compute UV2 centroid for each shell - int sc = shells.Count; - var centroids = new Vector2[sc]; - for (int i = 0; i < sc; i++) + var a3Arr = new double[n]; + var au2Arr = new double[n]; + var densArr = new double[n]; + var validShells = new List(n); + for (int si = 0; si < n; si++) { - Vector2 sum = Vector2.zero; - int cnt = 0; - foreach (int vi in shells[i].vertexIndices) + var shell = shells[si]; + if (shell?.faceIndices == null) continue; + double a3 = 0.0, au = 0.0; + foreach (int f in shell.faceIndices) { - if ((uint)vi < (uint)uv2.Length) - { - sum += uv2[vi]; - cnt++; - } + int t = f * 3; + if ((uint)(t + 2) >= (uint)tris.Length) continue; + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)posLen || (uint)i1 >= (uint)posLen || (uint)i2 >= (uint)posLen) continue; + if ((uint)i0 >= (uint)uvLen || (uint)i1 >= (uint)uvLen || (uint)i2 >= (uint)uvLen) continue; + Vector3 p0 = positions[i0], p1 = positions[i1], p2 = positions[i2]; + a3 += Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5; + Vector2 a = uv2[i0], b = uv2[i1], c = uv2[i2]; + au += Math.Abs((double)(b.x - a.x) * (c.y - a.y) - (double)(c.x - a.x) * (b.y - a.y)) * 0.5; } - centroids[i] = cnt > 0 ? sum / cnt : Vector2.zero; + a3Arr[si] = a3; + au2Arr[si] = au; + if (a3 < 1e-12 || au < 1e-12) continue; + densArr[si] = au / a3; + validShells.Add(si); } - // Build overlap groups using union-find so transitive chains - // (A near B, B near C) are merged into one group. - var parent = new int[sc]; - for (int i = 0; i < sc; i++) parent[i] = i; + if (validShells.Count == 0) return 0; - int FindRoot(int x) - { - while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } - return x; - } + // Median density as the target — robust to a handful of extreme outliers + // (degenerate L²=1000 shells) that would skew the mean. + var sortedDens = new List(validShells.Count); + foreach (int si in validShells) sortedDens.Add(densArr[si]); + sortedDens.Sort(); + double targetDensity = sortedDens[sortedDens.Count / 2]; + if (targetDensity < 1e-12) return 0; - for (int i = 0; i < sc; i++) - for (int j = i + 1; j < sc; j++) + int modified = 0; + double appliedScaleMin = 1.0, appliedScaleMax = 1.0; + foreach (int si in validShells) { - float dx = centroids[i].x - centroids[j].x; - float dy = centroids[i].y - centroids[j].y; - if (dx * dx + dy * dy < centroidThresholdSq) + double density = densArr[si]; + if (density <= targetDensity * 1.05) continue; // within 5% of target — leave alone + + // Bring density down to target. au2 scales with scale²; + // density_new = (au2 * scale²) / a3 = density * scale² = target + // → scale = sqrt(target / density). Shrink only. + double scaleD = Math.Sqrt(targetDensity / density); + if (!IsFiniteD(scaleD) || scaleD <= 0.0) continue; + if (scaleD >= 0.999) continue; // basically no-op + float scale = (float)scaleD; + if (scale < appliedScaleMin) appliedScaleMin = scale; + if (scale > appliedScaleMax) appliedScaleMax = scale; + + var shell = shells[si]; + if (shell.vertexIndices == null || shell.vertexIndices.Count == 0) continue; + + // UV2 centroid (uniform shrink leaves the centroid fixed → the + // shell stays where xatlas put it; only the bbox contracts + // inward, so neighbours stay outside the shrunken bbox). + Vector2 c = Vector2.zero; + int cn = 0; + foreach (int v in shell.vertexIndices) { - int ri = FindRoot(i), rj = FindRoot(j); - if (ri != rj) parent[ri] = rj; + int idx = v; + if ((uint)idx >= (uint)uvLen) continue; + c.x += uv2[idx].x; + c.y += uv2[idx].y; + cn++; } - } + if (cn == 0) continue; + c.x /= cn; + c.y /= cn; - // Collect groups with more than one member - var groupMap = new Dictionary>(); - for (int i = 0; i < sc; i++) - { - int root = FindRoot(i); - if (!groupMap.TryGetValue(root, out var g)) + foreach (int v in shell.vertexIndices) { - g = new List(); - groupMap[root] = g; + int idx = v; + if ((uint)idx >= (uint)uvLen) continue; + Vector2 uv = uv2[idx]; + uv2[idx] = new Vector2( + c.x + (uv.x - c.x) * scale, + c.y + (uv.y - c.y) * scale); } - g.Add(i); + modified++; } - var nearPairs = new List>(); - foreach (var g in groupMap.Values) - if (g.Count > 1) - nearPairs.Add(g); - - if (nearPairs.Count == 0) return 0; - - return FixOverlappingUv2Shells(uv2, shells, nearPairs, - padding, atlasWidth, atlasHeight, skipRescale); + UvtLog.Info(UvtLog.Category.Repack, + $"[Density:correction] '{meshLabel}' shrunk {modified}/{validShells.Count} over-dense shells toward median={targetDensity:G3} | applied scale: min={appliedScaleMin:F3} max={appliedScaleMax:F3}"); + return modified; } + static bool IsFiniteD(double x) => !(double.IsNaN(x) || double.IsInfinity(x)); + /// - /// If any UV2 coordinate exceeds [0,1], uniformly rescale all UV2 to fit. + /// Estimate per-shell density amplification that xatlas will apply + /// in Stage B (xatlas.cpp:8345-8362), BEFORE handing UVs to xatlas. + /// Stage B does texcoord *= ceil(extent)/extent per axis per + /// chart, so a shell with sub-pixel extent gets a multiplicative + /// boost = ceil(ext_x)/ext_x × ceil(ext_y)/ext_y on its UV + /// area (and therefore on its lightmap density). + /// + /// For UvMesh input the per-chart scale collapses to tpu + /// (since surfaceArea == parametricArea). xatlas estimates + /// tpu = sqrt(internalRes² / (meshArea/0.75)) when + /// texelsPerUnit == 0. We compute the same estimate here so + /// the prediction matches what xatlas will see internally. + /// + /// Logs the worst-offender shells (boost > 1.5×) at Info level so + /// the user can see whether a problematic spread is coming from + /// Stage B or from elsewhere in the pipeline. /// - static void RescaleUv2ToUnit(Vector2[] uv2) + static void LogStageBRisk( + float[] uvFlat, List shells, int[] tris, + uint internalRes, float texelsPerUnit, string meshLabel, string stageLabel) { - float maxU = 0f, maxV = 0f; - for (int i = 0; i < uv2.Length; i++) + if (uvFlat == null || shells == null || tris == null) return; + int vertCount = uvFlat.Length / 2; + + // Estimate tpu the way xatlas does (xatlas.cpp:8295-8306) when + // texelsPerUnit == 0: from total parametric area and 0.75 target. + float tpu = texelsPerUnit; + if (!(tpu > 0f)) { - if (uv2[i].x > maxU) maxU = uv2[i].x; - if (uv2[i].y > maxV) maxV = uv2[i].y; + double sumUv = 0.0; + for (int t = 0; t + 2 < tris.Length; t += 3) + { + int i0 = tris[t], i1 = tris[t + 1], i2 = tris[t + 2]; + if ((uint)i0 >= (uint)vertCount || (uint)i1 >= (uint)vertCount || (uint)i2 >= (uint)vertCount) continue; + double ax = uvFlat[i0 * 2], ay = uvFlat[i0 * 2 + 1]; + double bx = uvFlat[i1 * 2], by = uvFlat[i1 * 2 + 1]; + double cx = uvFlat[i2 * 2], cy = uvFlat[i2 * 2 + 1]; + sumUv += Math.Abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) * 0.5; + } + if (sumUv < 1e-12 || internalRes == 0) return; + double texelCount = Math.Max(1.0, sumUv / 0.75); + tpu = (float)Math.Sqrt((double)internalRes * internalRes / texelCount); } - if (maxU > 1f || maxV > 1f) + int n = shells.Count; + int subPixelShells = 0; + int badShells = 0; // boostArea > 1.5 + int severeShells = 0; // boostArea > 3.0 + double maxBoostArea = 1.0; + int worstIdx = -1; + float worstExtX = 0f, worstExtY = 0f, worstBoostX = 1f, worstBoostY = 1f; + + // Track top 5 worst for the log + var top = new (int idx, double area, float ex, float ey, float bx, float by)[5]; + int topCount = 0; + + for (int si = 0; si < n; si++) { - float scale = 1f / Mathf.Max(maxU, maxV); - UvtLog.Verbose($"[xatlas] Rescale UV2 to unit: maxU={maxU:F4} maxV={maxV:F4} scale={scale:F4}"); - for (int i = 0; i < uv2.Length; i++) - uv2[i] *= scale; + var shell = shells[si]; + if (shell?.vertexIndices == null || shell.vertexIndices.Count == 0) continue; + float minX = float.PositiveInfinity, minY = float.PositiveInfinity; + float maxX = float.NegativeInfinity, maxY = float.NegativeInfinity; + foreach (int vi in shell.vertexIndices) + { + if ((uint)vi >= (uint)vertCount) continue; + float u = uvFlat[vi * 2], v = uvFlat[vi * 2 + 1]; + if (u < minX) minX = u; if (u > maxX) maxX = u; + if (v < minY) minY = v; if (v > maxY) maxY = v; + } + if (!(maxX > minX) || !(maxY > minY)) continue; + float extX = (maxX - minX) * tpu; + float extY = (maxY - minY) * tpu; + if (extX <= 0f || extY <= 0f) continue; + float boostX = Mathf.Ceil(extX) / extX; + float boostY = Mathf.Ceil(extY) / extY; + double boostArea = boostX * boostY; + + if (extX < 1f || extY < 1f) subPixelShells++; + if (boostArea > 1.5) badShells++; + if (boostArea > 3.0) severeShells++; + if (boostArea > maxBoostArea) + { + maxBoostArea = boostArea; + worstIdx = si; + worstExtX = extX; worstExtY = extY; + worstBoostX = boostX; worstBoostY = boostY; + } + + // Insertion sort top-5 + if (topCount < 5 || boostArea > top[topCount - 1].area) + { + int insertAt = topCount; + while (insertAt > 0 && top[insertAt - 1].area < boostArea) insertAt--; + if (topCount < 5) topCount++; + for (int j = topCount - 1; j > insertAt; j--) top[j] = top[j - 1]; + if (insertAt < 5) top[insertAt] = (si, boostArea, extX, extY, boostX, boostY); + } + } + + UvtLog.Info(UvtLog.Category.Repack, + $"[DensityRisk:{stageLabel}] '{meshLabel}' tpu≈{tpu:F1} | shells={n} subPixel={subPixelShells} boost>1.5×={badShells} boost>3×={severeShells} | worst: shell#{worstIdx} extent={worstExtX:F2}×{worstExtY:F2}px boost={worstBoostX:F2}×{worstBoostY:F2} areaBoost={maxBoostArea:F2}×"); + + for (int i = 0; i < topCount && i < 5; i++) + { + if (top[i].area <= 1.5) break; + UvtLog.Verbose(UvtLog.Category.Repack, + $"[DensityRisk:{stageLabel}] #{top[i].idx} extent={top[i].ex:F2}×{top[i].ey:F2}px boost={top[i].bx:F2}×{top[i].by:F2} areaBoost={top[i].area:F2}×"); } } /// - /// Phase 2 overlap fix: relocate overlapping UV2 shells to free atlas space. - /// Uses an occupancy grid to find unoccupied rectangles for displaced shells. - /// Falls back to axis-shift if no free space is found. - /// Returns number of shells relocated. + /// Run xatlas ComputeCharts + PackCharts on a background thread while + /// the main thread polls a cancellable progress bar. xatlas itself + /// can't be interrupted mid-pack (no native cancel API), so a "cancel" + /// click here means: stop showing progress, wait for the in-flight + /// pack to finish (since xatlas state is a process-wide singleton — + /// can't safely abandon it), then return cancelled. The user gets + /// reactive UI feedback even when the pack is slow. + /// + /// Returns true if pack completed normally, false if user cancelled + /// (caller should clean up via xatlasDestroy and skip output read). /// - internal static int RelocateToFreeSpace( - Vector2[] uv2, List shells, - uint padding, uint atlasWidth, uint atlasHeight) + static long ComputePackCost(int shellCount, uint internalRes) { - if (shells.Count < 2) return 0; + long res = internalRes; + return (long)Math.Max(0, shellCount) * res * res; + } - float padU = atlasWidth > 0 ? (float)padding / atlasWidth : 0f; - float padV = atlasHeight > 0 ? (float)padding / atlasHeight : 0f; + static int ResolvePackBruteForce( + int bruteForce, int internalOversample, int shellCount, uint internalRes, + out string disabledReason) + { + disabledReason = null; + if (bruteForce == 0) return 0; - // Compute UV2 AABB per shell - int n = shells.Count; - var mn = new Vector2[n]; - var mx = new Vector2[n]; - for (int i = 0; i < n; i++) + int oversample = internalOversample > 0 ? internalOversample : 1; + long packCost = ComputePackCost(shellCount, internalRes); + + if (oversample > 1) { - mn[i] = new Vector2(float.MaxValue, float.MaxValue); - mx[i] = new Vector2(float.MinValue, float.MinValue); - foreach (int vi in shells[i].vertexIndices) - { - if ((uint)vi < (uint)uv2.Length) - { - mn[i] = Vector2.Min(mn[i], uv2[vi]); - mx[i] = Vector2.Max(mx[i], uv2[vi]); - } - } + disabledReason = + $"internal oversample {oversample}× uses heuristic pack — cost {packCost / 1_000_000L}M ops ({shellCount} shells × {internalRes}² atlas)"; + return 0; } - // Detect overlapping pairs - var overlapping = new HashSet(); - for (int i = 0; i < n; i++) + if (packCost > kBruteCostBudget) { - for (int j = i + 1; j < n; j++) - { - float oMinX = Mathf.Max(mn[i].x, mn[j].x); - float oMinY = Mathf.Max(mn[i].y, mn[j].y); - float oMaxX = Mathf.Min(mx[i].x, mx[j].x); - float oMaxY = Mathf.Min(mx[i].y, mx[j].y); - if (oMaxX <= oMinX || oMaxY <= oMinY) continue; - - float overlapArea = (oMaxX - oMinX) * (oMaxY - oMinY); - float areaI = (mx[i].x - mn[i].x) * (mx[i].y - mn[i].y); - float areaJ = (mx[j].x - mn[j].x) * (mx[j].y - mn[j].y); - float smaller = Mathf.Min(areaI, areaJ); - if (smaller <= 0f || overlapArea / smaller < 0.01f) continue; - - overlapping.Add(i); - overlapping.Add(j); - } + disabledReason = + $"cost {packCost / 1_000_000L}M ops ({shellCount} shells × {internalRes}² atlas) would exceed {kBruteCostBudget / 1_000_000L}M budget"; + return 0; } - if (overlapping.Count == 0) return 0; - - // Build occupancy grid from non-overlapping shells - const int kGridRes = 128; - int[,] grid = new int[kGridRes, kGridRes]; // 0 = free, 1 = occupied + return bruteForce; + } - for (int i = 0; i < n; i++) + static bool RunPackCancelable( + string label, int shellCount, uint internalRes, int internalOversample, + int maxChartSize, uint padding, float texelsPerUnit, uint resolution, + int bilinear, int blockAlign, int bruteForce, + int rotateCharts, int rotateChartsToAxis) + { + // Cost preflight — refuse impossibly large packs up front instead + // of hanging for hours. Brute force is O(shells × W × H); the + // random heuristic is much cheaper but still scales with area. + long packCost = ComputePackCost(shellCount, internalRes); + bruteForce = ResolvePackBruteForce( + bruteForce, internalOversample, shellCount, internalRes, + out string bruteDisabledReason); + if (!string.IsNullOrEmpty(bruteDisabledReason)) { - if (overlapping.Contains(i)) continue; - int gMinX = Mathf.Clamp(Mathf.FloorToInt(mn[i].x * kGridRes), 0, kGridRes - 1); - int gMinY = Mathf.Clamp(Mathf.FloorToInt(mn[i].y * kGridRes), 0, kGridRes - 1); - int gMaxX = Mathf.Clamp(Mathf.CeilToInt(mx[i].x * kGridRes), 0, kGridRes - 1); - int gMaxY = Mathf.Clamp(Mathf.CeilToInt(mx[i].y * kGridRes), 0, kGridRes - 1); - for (int gy = gMinY; gy <= gMaxY; gy++) - for (int gx = gMinX; gx <= gMaxX; gx++) - grid[gx, gy] = 1; + UvtLog.Info(UvtLog.Category.Repack, + $"[xatlas] Brute force pack disabled — {bruteDisabledReason}"); + } + if (packCost > kHeuristicCostBudget) + { + UvtLog.Warn(UvtLog.Category.Repack, + $"[xatlas] Pack cost {packCost / 1_000_000_000L}B ops is past the {kHeuristicCostBudget / 1_000_000_000L}B safety budget — refusing to start pack. Lower internal oversample or atlas resolution."); + return false; } - // Build summed area table for O(1) rectangle occupancy queries. - // sat[x,y] = sum of grid[0..x-1, 0..y-1]. - int[,] sat = new int[kGridRes + 1, kGridRes + 1]; - for (int y = 0; y < kGridRes; y++) - for (int x = 0; x < kGridRes; x++) - sat[x + 1, y + 1] = grid[x, y] + sat[x, y + 1] + sat[x + 1, y] - sat[x, y]; - // Sort overlapping shells by area (largest first) for better packing - var toRelocate = new List(overlapping); - toRelocate.Sort((a, b) => + var packTask = Task.Run(() => { - float areaA = (mx[a].x - mn[a].x) * (mx[a].y - mn[a].y); - float areaB = (mx[b].x - mn[b].x) * (mx[b].y - mn[b].y); - return areaB.CompareTo(areaA); + XatlasNative.xatlasComputeCharts(); + XatlasNative.xatlasPackCharts( + maxChartSize, padding, texelsPerUnit, resolution, + bilinear, blockAlign, bruteForce, + rotateCharts, rotateChartsToAxis); }); - int relocated = 0; - foreach (int si in toRelocate) + double startTime = EditorApplication.timeSinceStartup; + bool cancelled = false; + try { - float w = mx[si].x - mn[si].x + padU * 2f; - float h = mx[si].y - mn[si].y + padV * 2f; - int gw = Mathf.Max(1, Mathf.CeilToInt(w * kGridRes)); - int gh = Mathf.Max(1, Mathf.CeilToInt(h * kGridRes)); - - // Scan for free rectangle using summed area table (O(1) per query) - bool placed = false; - for (int gy = 0; gy <= kGridRes - gh && !placed; gy++) + while (!packTask.IsCompleted) { - for (int gx = 0; gx <= kGridRes - gw && !placed; gx++) + double elapsed = EditorApplication.timeSinceStartup - startTime; + string msg = $"{label} — atlas {internalRes}×{internalRes}, {shellCount} shells, {elapsed:F0}s elapsed"; + if (EditorUtility.DisplayCancelableProgressBar("xatlas pack", msg, -1f)) { - int sum = sat[gx + gw, gy + gh] - sat[gx, gy + gh] - - sat[gx + gw, gy] + sat[gx, gy]; - if (sum != 0) continue; - - // Place shell here - float newMinX = (float)gx / kGridRes + padU; - float newMinY = (float)gy / kGridRes + padV; - float offX = newMinX - mn[si].x; - float offY = newMinY - mn[si].y; - - foreach (int vi in shells[si].vertexIndices) - if ((uint)vi < (uint)uv2.Length) - uv2[vi] = new Vector2(uv2[vi].x + offX, uv2[vi].y + offY); - - // Mark occupied in grid and rebuild SAT incrementally - for (int dy = 0; dy < gh; dy++) - for (int dx = 0; dx < gw; dx++) - grid[gx + dx, gy + dy] = 1; - for (int y = gy; y < kGridRes; y++) - for (int x = gx; x < kGridRes; x++) - sat[x + 1, y + 1] = grid[x, y] + sat[x, y + 1] + sat[x + 1, y] - sat[x, y]; - - mn[si] = new Vector2(mn[si].x + offX, mn[si].y + offY); - mx[si] = new Vector2(mx[si].x + offX, mx[si].y + offY); - - UvtLog.Verbose($"[xatlas] Free-space relocate: shell {si} → " + - $"({newMinX:F3},{newMinY:F3}) offset=({offX:F4},{offY:F4})"); - placed = true; - relocated++; + cancelled = true; + break; } + System.Threading.Thread.Sleep(50); } - if (!placed) - { - UvtLog.Verbose($"[xatlas] Free-space fallback: shell {si} — no free space, " + - $"using axis shift"); - // Mark this shell's current position as occupied anyway - int fgMinX = Mathf.Clamp(Mathf.FloorToInt(mn[si].x * kGridRes), 0, kGridRes - 1); - int fgMinY = Mathf.Clamp(Mathf.FloorToInt(mn[si].y * kGridRes), 0, kGridRes - 1); - int fgMaxX = Mathf.Clamp(Mathf.CeilToInt(mx[si].x * kGridRes), 0, kGridRes - 1); - int fgMaxY = Mathf.Clamp(Mathf.CeilToInt(mx[si].y * kGridRes), 0, kGridRes - 1); - for (int fy = fgMinY; fy <= fgMaxY; fy++) - for (int fx = fgMinX; fx <= fgMaxX; fx++) - grid[fx, fy] = 1; - for (int y = fgMinY; y < kGridRes; y++) - for (int x = fgMinX; x < kGridRes; x++) - sat[x + 1, y + 1] = grid[x, y] + sat[x, y + 1] + sat[x + 1, y] - sat[x, y]; - } + // xatlas has no native cancel — wait for the task to actually + // finish before returning, otherwise the singleton state is + // mid-mutation and the next xatlasCreate would race against it. + packTask.Wait(); + } + finally + { + EditorUtility.ClearProgressBar(); + } + + if (cancelled) + UvtLog.Warn(UvtLog.Category.Repack, "[xatlas] Pack cancelled by user (xatlas finished its in-flight operation; result discarded)"); + + return !cancelled; + } + + /// + /// Raw xatlas output density diagnostic. Operates on the buffers returned + /// by xatlasGetOutputVertexData/Indices (atlas-pixel space, grouped by + /// xatlas chart ID, not by our original shell IDs). + /// + /// xatlas applies a single global texelsPerUnit scale to every chart + /// (sqrt(surfaceArea/parametricArea) == 1 for UvMesh input), so per-chart + /// UV-area ratios should match the *input* UV-area ratios exactly. If + /// they don't, something inside xatlas's pack stage (maxChartSize clamp, + /// per-chart rotation+fit, sub-atlas split) altered them. + /// + /// Note: this measures area per xatlas chart, NOT per original shell. + /// A shell that xatlas split into N charts will appear as N rows here. + /// + static void LogRawXatlasDensity( + float[] outUV, uint[] outChart, uint[] outIdx, + int outVertCount, int outIndexCount, + string meshLabel) + { + if (outUV == null || outChart == null || outIdx == null) return; + int triCount = outIndexCount / 3; + if (triCount == 0) return; + + // Build chart → list of triangle areas. + var chartArea = new Dictionary(); + for (int t = 0; t < triCount; t++) + { + int ti = t * 3; + uint i0 = outIdx[ti], i1 = outIdx[ti + 1], i2 = outIdx[ti + 2]; + if (i0 >= (uint)outVertCount || i1 >= (uint)outVertCount || i2 >= (uint)outVertCount) continue; + // Chart ID — pick from one vertex; all three should match for an unsplit tri. + uint cId = outChart[i0]; + int u0 = (int)i0 * 2, u1 = (int)i1 * 2, u2 = (int)i2 * 2; + double ax = outUV[u0], ay = outUV[u0 + 1]; + double bx = outUV[u1], by = outUV[u1 + 1]; + double cx = outUV[u2], cy = outUV[u2 + 1]; + double a = Math.Abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)) * 0.5; + if (chartArea.TryGetValue(cId, out double cur)) + chartArea[cId] = cur + a; + else + chartArea[cId] = a; } - if (relocated > 0) + if (chartArea.Count == 0) return; + double cMin = double.MaxValue, cMax = 0.0, cSum = 0.0; + foreach (var kv in chartArea) { - RescaleUv2ToUnit(uv2); - UvtLog.Info($"[xatlas] Free-space relocator: placed {relocated}/{toRelocate.Count} overlapping shells"); + if (kv.Value <= 0.0) continue; + if (kv.Value < cMin) cMin = kv.Value; + if (kv.Value > cMax) cMax = kv.Value; + cSum += kv.Value; } + double cMean = cSum / chartArea.Count; + double cRatio = (cMin > 1e-30) ? cMax / cMin : 0.0; - return relocated; + UvtLog.Info(UvtLog.Category.Repack, + $"[Density:xatlasRaw] '{meshLabel}' charts={chartArea.Count} | chart UV-area (atlas-px²): min={cMin:G3} max={cMax:G3} mean={cMean:G3} maxRatio={cRatio:F2}x"); } + /// /// Convenience wrapper: repack UV0 shells into UV2, return packed UV2 array. /// Does NOT modify the original mesh. @@ -504,27 +856,21 @@ internal static int RelocateToFreeSpace( public static Vector2[] RepackUv(Mesh mesh, Vector2[] uv0, uint[] faceShellIds, int resolution, int padding, bool rotate) { - var opts = new RepackOptions - { - resolution = (uint)resolution, - padding = (uint)padding, - texelsPerUnit = 0f, - bilinear = true, - blockAlign = false, - bruteForce = false, - }; + var opts = RepackOptions.Default; + opts.resolution = (uint)resolution; + opts.padding = (uint)padding; // Work on a temporary copy so original mesh is untouched - var tmp = Object.Instantiate(mesh); + var tmp = UnityEngine.Object.Instantiate(mesh); tmp.name = mesh.name + "_repack_tmp"; var result = RepackSingle(tmp, opts); if (!result.ok) { - Object.DestroyImmediate(tmp); + UnityEngine.Object.DestroyImmediate(tmp); return null; } var uvOut = new List(); tmp.GetUVs(1, uvOut); - Object.DestroyImmediate(tmp); + UnityEngine.Object.DestroyImmediate(tmp); return uvOut.ToArray(); } @@ -544,6 +890,12 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) int vertCount = mesh.vertexCount; int faceCount = tris.Length / 3; + // 3D vertex positions — needed by tile-merge guard to compare + // shell 3D AABB size (rejects same-UV0-region shells whose 3D + // shapes differ, e.g. wood-plank vs box-lid sharing the same + // wood-texture UV0 region). + Vector3[] positions = mesh.vertices; + // ── Extract shells + build per-face shell IDs ── List shells; List> overlapGroups; @@ -556,6 +908,11 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) UvtLog.Verbose($"[xatlas] Pre-repack: {shells.Count} shells, " + $"{overlapGroups.Count} overlap groups, {overlapPairCount} overlapping pairs"); + // Hard-edge shell-split analysis. Pure diagnostic for now — the + // result is logged but the perFaceComponent map is not applied to + // faceShellIds. A future opt-in will materialise the split. + LogHardEdgeAnalysis(shells, tris, mesh.vertices, meshLabel: mesh.name); + // UV0 winding normalized by ExecWeldUv0. result.flippedShells = 0; @@ -567,13 +924,96 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) uvFlat[i * 2 + 1] = uv0[i].y; } - // ── Perturb overlapping shells to break xatlas packing symmetry ── - PerturbOverlapShellsUv0(uvFlat, shells, overlapGroups); + // ── Pre-pack pipeline ── + // Two stages: + // 1. ARAP re-parameterization of stretched shells (Sander L² + // gate) — fixes per-shell distortion at the vertex level. + // 2. Texel density correction (per-shell uniform scale) — + // equalises post-ARAP shell areas. Uniform scale doesn't + // distort the just-relaxed shapes. + // + // The earlier global-aspect bbox-to-1:1 pre-pass was removed: + // xatlas does not require a 1:1 input UV0, and anisotropic global + // scale only fought ARAP's per-shell output. Operates on the + // local uvFlat copy; mesh.uv is untouched. + + if (opts.reparameterizeStretchedShells) + { + int stretchedFound = 0, converged = 0, skipped = 0; + for (int si = 0; si < shells.Count; si++) + { + var shell = shells[si]; + if (shell?.vertexIndices == null || shell.vertexIndices.Count < 3) continue; + float l2 = ShellQuality.ComputeL2Stretch(shell, tris, positions, uvFlat); + if (float.IsNaN(l2) || l2 < opts.stretchThreshold) continue; + stretchedFound++; + var shellTriIndices = shell.faceIndices?.ToArray() ?? new int[0]; + if (shellTriIndices.Length == 0) { skipped++; continue; } + if (ArapParameterization.Reparameterize( + positions, tris, shellTriIndices, shell.vertexIndices, + uvFlat, opts.arapIterations, out int _initFlipped)) + { + converged++; + UvtLog.Verbose(UvtLog.Category.Repack, + $"[Repack] ARAP: shell {si} L²={l2:F2} (>{opts.stretchThreshold:F2}) → reparameterized"); + } + else + skipped++; + } + if (stretchedFound > 0) + UvtLog.Info(UvtLog.Category.Repack, + $"[Repack] ARAP: reparameterized {converged}/{stretchedFound} stretched shells (L²>{opts.stretchThreshold:F2}, skipped {skipped})"); + } + + if (opts.normalizeTexelDensity) + { + // Normalize logs its own [Density] summary at Info level + // (pre/post au/a3 distribution + scale spread). + TexelDensityNormalizer.Normalize( + uvFlat, shells, tris, positions, + targetCoverage: opts.targetUvCoverage); + } + + // Diagnostic: predict xatlas Stage B per-chart amplification + // (ceil(extent)/extent per axis) on the actual UVs we hand + // xatlas. Reveals which shells are sub-pixel risks BEFORE the + // pack — so we know whether the remaining density spread is + // legitimate (real sub-pixel ribbons) or our own doing. + { + int oversamplePre = opts.internalOversample > 0 ? opts.internalOversample : 1; + uint internalResPre = opts.resolution * (uint)oversamplePre; + LogStageBRisk(uvFlat, shells, tris, internalResPre, opts.texelsPerUnit, mesh.name, "prePack"); + } - // ── Flatten indices ── + // NOTE: PerturbOverlapShellsUv0 was a no-op for AddUvMesh paths + // — xatlas does NOT dedup UvMesh charts by UV similarity. It + // segments faces into charts by faceMaterial (= our shellID, + // unique per shell) plus colocal-UV walk gated by + // vertexToChartMap (xatlas.cpp:6261-6279), so distinct + // shellIDs always land in distinct charts regardless of UV + // overlap. The perturb was multiplying sumUV by ~100× on + // models with large overlap groups, collapsing the auto-tpu + // and pushing every shell back into the sub-pixel regime. + // Removed; if a UV-dedup workaround is ever needed it should + // be an area-preserving shear, not a cumulative scale. + + // ── Group-aware merge of overlapping shells ── + // Tiled-UV0 models (Wooden_Box_Long etc.) carry N>>K shells where + // K-many representative patches are duplicated N times by tile + // instancing — all overlapping in UV0. xatlas would pack the N + // independent charts and leave most of the atlas empty. Instead, + // we pick one representative per overlap-group, feed xatlas ONLY + // Flatten triangle indices into the uint32 buffer xatlas wants. + // Every shell is fed to xatlas as its own chart — UV2 must be + // unique per shell (lightmap channel); the previous "merge + // overlapping tiles" mode that collapsed tile-instances into one + // shared chart was removed because it produced incorrect baked + // lighting for instanced parts. uint[] indices = new uint[tris.Length]; for (int i = 0; i < tris.Length; i++) indices[i] = (uint)tris[i]; + uint[] xatlasFaceShellIds = faceShellIds; + uint xatlasFaceCount = (uint)faceCount; // ── xatlas pipeline ── XatlasNative.xatlasCreate(); @@ -583,7 +1023,7 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) int addErr = XatlasNative.xatlasAddUvMesh( uvFlat, (uint)vertCount, indices, (uint)indices.Length, - faceShellIds, (uint)faceCount); + xatlasFaceShellIds, xatlasFaceCount); if (addErr != 0) { @@ -591,13 +1031,30 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) return result; } - XatlasNative.xatlasComputeCharts(); - - XatlasNative.xatlasPackCharts( - 0, opts.padding, opts.texelsPerUnit, opts.resolution, + // Oversample the internal xatlas atlas. xatlas's unconditional + // per-chart ceil(extents) stretch (xatlas.cpp:8345-8362) breaks + // uniform density when shells have sub-pixel extents in atlas + // space. Running the pack at oversample× the user-facing + // resolution makes every chart's extent oversample× larger, so + // ceil rounding becomes fractional. Padding scales by the same + // factor to keep the gap fraction in UV space constant. + int oversample = opts.internalOversample > 0 ? opts.internalOversample : 1; + uint internalRes = opts.resolution * (uint)oversample; + uint internalPad = opts.padding * (uint)oversample; + + bool packed = RunPackCancelable( + mesh.name, shells.Count, internalRes, oversample, + opts.maxChartSize, internalPad, opts.texelsPerUnit, internalRes, opts.bilinear ? 1 : 0, opts.blockAlign ? 1 : 0, - opts.bruteForce ? 1 : 0); + opts.bruteForce ? 1 : 0, + opts.rotateCharts ? 1 : 0, + opts.rotateChartsToAxis ? 1 : 0); + if (!packed) + { + result.error = "cancelled"; + return result; + } if (XatlasNative.xatlasGetMeshCount() == 0) { @@ -609,6 +1066,9 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) result.atlasHeight = XatlasNative.xatlasGetAtlasHeight(); result.chartCount = XatlasNative.xatlasGetChartCount(); + UvtLog.Info(UvtLog.Category.Repack, + $"xatlas pack '{mesh.name}': req={opts.resolution}, actual={result.atlasWidth}x{result.atlasHeight}, charts={result.chartCount}"); + // ── Get raw output data ── int outVertCount = XatlasNative.xatlasGetOutputVertexCount(0); int outIndexCount = XatlasNative.xatlasGetOutputIndexCount(0); @@ -627,6 +1087,8 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) XatlasNative.xatlasGetOutputVertexData(0, outXref, outUV, outChart, outVertCount); XatlasNative.xatlasGetOutputIndices(0, outIdx, outIndexCount); + LogRawXatlasDensity(outUV, outChart, outIdx, outVertCount, outIndexCount, mesh.name); + // ── C#-side UV2 assignment ── Vector2[] uv2; uint[] vertChartId; @@ -638,22 +1100,7 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) result.conflictVertices = conflicts; - // ── Post-process: fix overlapping UV2 shells ── - // Phase 1: known UV0 overlap groups (fast path, catches SymSplit halves in same group) - FixOverlappingUv2Shells(uv2, shells, overlapGroups, - opts.padding, result.atlasWidth, result.atlasHeight, skipRescale: true); - - // Phase 2: centroid-proximity safety net — find shells packed at - // nearly identical UV2 positions (true SymSplit near-duplicates). - // Only checks pairs within 4px centroid distance, avoiding the - // false positives of the old global N² pass on dense atlases. - FixNearDuplicateUv2Shells(uv2, shells, - opts.padding, result.atlasWidth, result.atlasHeight); - - // Phase 3: free-space relocator for any remaining overlaps. - if (shells.Count > 1) - RelocateToFreeSpace(uv2, shells, - opts.padding, result.atlasWidth, result.atlasHeight); + LogPostPackDensity(uv2, tris, positions, shells, mesh.name + " [postAssign]"); // ── Post-process: fix orphan vertices ── int orphanVerts, orphanTris, snapped; @@ -662,17 +1109,45 @@ public static RepackResult RepackSingle(Mesh mesh, RepackOptions opts) result.orphanTriangles = orphanTris; result.snappedVertices = snapped; + LogPostPackDensity(uv2, tris, positions, shells, mesh.name + " [postOrphan]"); + // ── Diagnostic: top longest UV2 edges (after fix) ── DiagnoseLongestEdges(uv2, tris, faceShellIds, vertChartId, 10); + if (opts.postPackDensityCorrection) + { + ApplyPostPackDensityCorrection(uv2, tris, positions, shells, mesh.name); + LogPostPackDensity(uv2, tris, positions, shells, mesh.name + " [postCorrection]"); + } + // ── Border padding inset ── if (opts.borderPadding > 0 && result.atlasWidth > 0) - ApplyBorderInset(uv2, opts.borderPadding, result.atlasWidth, result.atlasHeight); + { + // Inset is computed against user-facing resolution, not the + // oversampled internal atlas dims — otherwise border pixels + // become sub-pixel in the final lightmap. + uint refAtlasW = opts.resolution > 0 ? opts.resolution : result.atlasWidth; + uint refAtlasH = opts.resolution > 0 ? opts.resolution : result.atlasHeight; + ApplyBorderInset(uv2, opts.borderPadding, refAtlasW, refAtlasH); + LogPostPackDensity(uv2, tris, positions, shells, mesh.name + " [postBorder]"); + } // ── Apply UV2 (channel 1 — Unity lightmap channel, mesh.uv2) ── + int clampedOutOfUnit = 0; + if (opts.clampLightmapToUnit) + clampedOutOfUnit = ClampUvsToUnit(uv2); mesh.SetUVs(1, uv2); + if (clampedOutOfUnit > 0) + UvtLog.Verbose(UvtLog.Category.Repack, + $"Clamped {clampedOutOfUnit} UV2 vert(s) into [0,1]"); result.ok = true; + double coverage = ComputeUv2CoverageFraction(uv2, tris); + UvtLog.Info(UvtLog.Category.Repack, + $"Atlas utilization: {coverage * 100.0:F1}% of [0,1]² covered ({shells.Count} shells)"); + + LogPostPackDensity(uv2, tris, positions, shells, mesh.name + " [final]"); + // ── Stats ── int nonZero = 0; float minU = float.MaxValue, maxU = float.MinValue; @@ -713,6 +1188,7 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) // ── Per-mesh pre-processing data ── var allUv0 = new Vector2[meshCount][]; var allTris = new int[meshCount][]; + var allPositions = new Vector3[meshCount][]; var allShells = new List[meshCount]; var allOverlap = new List>[meshCount]; var allFaceShells = new uint[meshCount][]; @@ -728,6 +1204,7 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) return results; } allTris[m] = mesh.triangles; + allPositions[m] = mesh.vertices; List shells; List> overlapGroups; allFaceShells[m] = UvShellExtractor.BuildPerFaceShellIds( @@ -741,12 +1218,18 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) UvtLog.Verbose($"[xatlas] Pre-repack mesh {m}: {shells.Count} shells, " + $"{overlapGroups.Count} overlap groups, {overlapPairs} overlapping pairs"); + LogHardEdgeAnalysis(shells, allTris[m], allPositions[m], meshLabel: mesh.name); } // UV0 winding normalized by ExecWeldUv0. for (int m = 0; m < meshCount; m++) results[m].flippedShells = 0; + // Local UV0 copies (flattened) per mesh — fed to xatlas, mutated + // by pre-pack passes (ARAP + density normalisation + perturbation); + // mesh.uv is never touched. + var allUvFlat = new float[meshCount][]; + // ── Single xatlas session for all meshes ── XatlasNative.xatlasCreate(); try @@ -764,18 +1247,70 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) uvFlat[i * 2] = allUv0[m][i].x; uvFlat[i * 2 + 1] = allUv0[m][i].y; } + allUvFlat[m] = uvFlat; + + // Pre-pack pipeline (same two stages as RepackSingle): + // 1. ARAP on stretched shells (Sander L²). + // 2. Texel density (per-shell uniform scale). + if (opts.reparameterizeStretchedShells) + { + int stretchedFoundM = 0, convergedM = 0, skippedM = 0; + for (int si = 0; si < allShells[m].Count; si++) + { + var shell = allShells[m][si]; + if (shell?.vertexIndices == null || shell.vertexIndices.Count < 3) continue; + float l2 = ShellQuality.ComputeL2Stretch(shell, allTris[m], allPositions[m], uvFlat); + if (float.IsNaN(l2) || l2 < opts.stretchThreshold) continue; + stretchedFoundM++; + var shellTriIndices = shell.faceIndices?.ToArray() ?? new int[0]; + if (shellTriIndices.Length == 0) { skippedM++; continue; } + if (ArapParameterization.Reparameterize( + allPositions[m], allTris[m], shellTriIndices, shell.vertexIndices, + uvFlat, opts.arapIterations, out int _initFlippedM)) + { + convergedM++; + UvtLog.Verbose(UvtLog.Category.Repack, + $"[Repack] ARAP mesh {m}: shell {si} L²={l2:F2} (>{opts.stretchThreshold:F2}) → reparameterized"); + } + else + skippedM++; + } + if (stretchedFoundM > 0) + UvtLog.Info(UvtLog.Category.Repack, + $"[Repack] ARAP mesh {m}: reparameterized {convergedM}/{stretchedFoundM} stretched shells (L²>{opts.stretchThreshold:F2}, skipped {skippedM})"); + } + + if (opts.normalizeTexelDensity) + { + // Normalize logs its own [Density] summary at Info level. + TexelDensityNormalizer.Normalize( + uvFlat, allShells[m], allTris[m], allPositions[m], + targetCoverage: opts.targetUvCoverage); + } - // Perturb overlapping shells to break xatlas packing symmetry - PerturbOverlapShellsUv0(uvFlat, allShells[m], allOverlap[m]); + // Diagnostic: predict xatlas Stage B amplification on + // the actual UVs we hand xatlas. See RepackSingle for + // the rationale on removing the previous Perturb call. + { + int oversamplePreM = opts.internalOversample > 0 ? opts.internalOversample : 1; + uint internalResPreM = opts.resolution * (uint)oversamplePreM; + LogStageBRisk(uvFlat, allShells[m], allTris[m], internalResPreM, opts.texelsPerUnit, meshes[m]?.name ?? $"mesh#{m}", "prePack"); + } + // Every shell is fed to xatlas as its own chart (UV2 is + // a unique-per-shell channel; the deleted "merge overlap + // tiles" mode produced shared lightmap regions for tile + // instances which is incorrect bake output). uint[] indices = new uint[allTris[m].Length]; for (int i = 0; i < allTris[m].Length; i++) indices[i] = (uint)allTris[m][i]; + uint[] xatlasFaceShellIds = allFaceShells[m]; + uint xatlasFaceCount = (uint)faceCount; int addErr = XatlasNative.xatlasAddUvMesh( uvFlat, (uint)vertCount, indices, (uint)indices.Length, - allFaceShells[m], (uint)faceCount); + xatlasFaceShellIds, xatlasFaceCount); if (addErr != 0) { @@ -785,12 +1320,30 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) } // Pack all charts together into one atlas - XatlasNative.xatlasComputeCharts(); - XatlasNative.xatlasPackCharts( - 0, opts.padding, opts.texelsPerUnit, opts.resolution, + // See RepackSingle for oversample rationale (ceil-stretch fix) + // and RunPackCancelable for cost-budget + cancel handling. + int oversampleM = opts.internalOversample > 0 ? opts.internalOversample : 1; + uint internalResM = opts.resolution * (uint)oversampleM; + uint internalPadM = opts.padding * (uint)oversampleM; + + int totalShellsM = 0; + for (int m = 0; m < meshCount; m++) + if (allShells[m] != null) totalShellsM += allShells[m].Count; + + bool packedM = RunPackCancelable( + "MultiMesh", totalShellsM, internalResM, oversampleM, + opts.maxChartSize, internalPadM, opts.texelsPerUnit, internalResM, opts.bilinear ? 1 : 0, opts.blockAlign ? 1 : 0, - opts.bruteForce ? 1 : 0); + opts.bruteForce ? 1 : 0, + opts.rotateCharts ? 1 : 0, + opts.rotateChartsToAxis ? 1 : 0); + if (!packedM) + { + for (int m = 0; m < meshCount; m++) + results[m].error = "cancelled"; + return results; + } int outMeshCount = XatlasNative.xatlasGetMeshCount(); if (outMeshCount == 0) @@ -808,7 +1361,6 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) // ── Per-mesh output extraction ── var allUv2 = new Vector2[meshCount][]; - int totalShifted = 0; for (int m = 0; m < meshCount; m++) { @@ -836,6 +1388,8 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) XatlasNative.xatlasGetOutputVertexData(m, outXref, outUV, outChart, outVertCount); XatlasNative.xatlasGetOutputIndices(m, outIdx, outIndexCount); + LogRawXatlasDensity(outUV, outChart, outIdx, outVertCount, outIndexCount, meshes[m].name); + results[m].chartCount = (uint)outVertCount; // per-mesh chart count approximation // Assign UV2 @@ -848,18 +1402,7 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) out uv2, out vertChartId, out conflicts); results[m].conflictVertices = conflicts; - // Fix overlapping UV2 shells (skip per-mesh rescale — do global rescale below) - totalShifted += FixOverlappingUv2Shells(uv2, allShells[m], allOverlap[m], - opts.padding, atlasW, atlasH, skipRescale: true); - - // Centroid-proximity safety net for near-duplicate SymSplit shells - totalShifted += FixNearDuplicateUv2Shells(uv2, allShells[m], - opts.padding, atlasW, atlasH, skipRescale: true); - - // Free-space relocator for any remaining overlaps - if (allShells[m].Count > 1) - totalShifted += RelocateToFreeSpace(uv2, allShells[m], - opts.padding, atlasW, atlasH); + LogPostPackDensity(uv2, allTris[m], allPositions[m], allShells[m], meshes[m].name + " [postAssign]"); // Fix orphan vertices int orphanVerts, orphanTris, snapped; @@ -868,45 +1411,47 @@ public static RepackResult[] RepackMulti(Mesh[] meshes, RepackOptions opts) results[m].orphanTriangles = orphanTris; results[m].snappedVertices = snapped; - allUv2[m] = uv2; - results[m].ok = true; - } + LogPostPackDensity(uv2, allTris[m], allPositions[m], allShells[m], meshes[m].name + " [postOrphan]"); - // Global rescale across all meshes to maintain cross-mesh UV2 consistency - if (totalShifted > 0) - { - float maxU = 0f, maxV = 0f; - for (int m = 0; m < meshCount; m++) + if (opts.postPackDensityCorrection) { - if (allUv2[m] == null) continue; - for (int i = 0; i < allUv2[m].Length; i++) - { - if (allUv2[m][i].x > maxU) maxU = allUv2[m][i].x; - if (allUv2[m][i].y > maxV) maxV = allUv2[m][i].y; - } - } - if (maxU > 1f || maxV > 1f) - { - float scale = 1f / Mathf.Max(maxU, maxV); - for (int m = 0; m < meshCount; m++) - { - if (allUv2[m] == null) continue; - for (int i = 0; i < allUv2[m].Length; i++) - allUv2[m][i] *= scale; - } + ApplyPostPackDensityCorrection(uv2, allTris[m], allPositions[m], allShells[m], meshes[m].name); + LogPostPackDensity(uv2, allTris[m], allPositions[m], allShells[m], meshes[m].name + " [postCorrection]"); } + + allUv2[m] = uv2; + results[m].ok = true; + + double coverageM = ComputeUv2CoverageFraction(uv2, allTris[m]); + UvtLog.Info(UvtLog.Category.Repack, + $"Atlas utilization mesh {m}: {coverageM * 100.0:F1}% of [0,1]² covered ({allShells[m].Count} shells)"); } - // Apply UV2 and border padding + // Apply UV2, border padding, and atlas-fill normalization + int clampedTotal = 0; for (int m = 0; m < meshCount; m++) { if (allUv2[m] == null || !results[m].ok) continue; if (opts.borderPadding > 0 && atlasW > 0) - ApplyBorderInset(allUv2[m], opts.borderPadding, atlasW, atlasH); + { + // Inset against user-facing resolution, not oversampled atlas. + uint refAtlasW = opts.resolution > 0 ? opts.resolution : atlasW; + uint refAtlasH = opts.resolution > 0 ? opts.resolution : atlasH; + ApplyBorderInset(allUv2[m], opts.borderPadding, refAtlasW, refAtlasH); + LogPostPackDensity(allUv2[m], allTris[m], allPositions[m], allShells[m], meshes[m].name + " [postBorder]"); + } + + if (opts.clampLightmapToUnit) + clampedTotal += ClampUvsToUnit(allUv2[m]); meshes[m].SetUVs(1, allUv2[m]); + + LogPostPackDensity(allUv2[m], allTris[m], allPositions[m], allShells[m], meshes[m].name + " [final]"); } + if (clampedTotal > 0) + UvtLog.Verbose(UvtLog.Category.Repack, + $"Clamped {clampedTotal} UV2 vert(s) into [0,1] across {meshCount} mesh(es)"); } finally { @@ -1150,6 +1695,28 @@ static void CheckEdge(List list, ref float minKeep, int topN, // Border padding inset: shrink UV layout away from atlas edges // uv = uv * (1 - 2*inset) + inset where inset = borderPx / atlasSize // ───────────────────────────────────────────────────────────────── + /// + /// Clamps every UV2 coord into [0,1] in place. Returns the number of + /// vertices that had at least one axis outside the unit square (only + /// counts vertices, not axes, so a vert with both axes out is still 1). + /// + internal static int ClampUvsToUnit(Vector2[] uv2) + { + if (uv2 == null) return 0; + int n = 0; + for (int i = 0; i < uv2.Length; i++) + { + Vector2 v = uv2[i]; + bool outside = v.x < 0f || v.x > 1f || v.y < 0f || v.y > 1f; + if (outside) + { + uv2[i] = new Vector2(Mathf.Clamp01(v.x), Mathf.Clamp01(v.y)); + n++; + } + } + return n; + } + static void ApplyBorderInset(Vector2[] uv2, uint borderPx, uint atlasW, uint atlasH) { float insetX = (float)borderPx / atlasW; diff --git a/Native~/xatlas-unity-bridge.cpp b/Native~/xatlas-unity-bridge.cpp index 152e026e..c3089669 100644 --- a/Native~/xatlas-unity-bridge.cpp +++ b/Native~/xatlas-unity-bridge.cpp @@ -76,18 +76,22 @@ EXPORT void xatlasPackCharts( uint32_t resolution, int bilinear, int blockAlign, - int bruteForce) + int bruteForce, + int rotateCharts, + int rotateChartsToAxis) { if (!s_atlas) return; xatlas::PackOptions opts; - opts.maxChartSize = maxChartSize; - opts.padding = padding; - opts.texelsPerUnit = texelsPerUnit; - opts.resolution = resolution; - opts.bilinear = (bilinear != 0); - opts.blockAlign = (blockAlign != 0); - opts.bruteForce = (bruteForce != 0); + opts.maxChartSize = maxChartSize; + opts.padding = padding; + opts.texelsPerUnit = texelsPerUnit; + opts.resolution = resolution; + opts.bilinear = (bilinear != 0); + opts.blockAlign = (blockAlign != 0); + opts.bruteForce = (bruteForce != 0); + opts.rotateCharts = (rotateCharts != 0); + opts.rotateChartsToAxis = (rotateChartsToAxis != 0); xatlas::PackCharts(s_atlas, opts); } diff --git a/Plugins/macOS/libxatlas-unity.dylib b/Plugins/macOS/libxatlas-unity.dylib index 8017d1c6..a46a8960 100644 Binary files a/Plugins/macOS/libxatlas-unity.dylib and b/Plugins/macOS/libxatlas-unity.dylib differ diff --git a/Plugins/x86_64/libxatlas-unity.so b/Plugins/x86_64/libxatlas-unity.so index 619a6d35..10f1cd1c 100755 Binary files a/Plugins/x86_64/libxatlas-unity.so and b/Plugins/x86_64/libxatlas-unity.so differ diff --git a/Plugins/x86_64/xatlas-unity.dll b/Plugins/x86_64/xatlas-unity.dll index ee7a719d..cb65e186 100755 Binary files a/Plugins/x86_64/xatlas-unity.dll and b/Plugins/x86_64/xatlas-unity.dll differ diff --git a/Shaders/VertexAORayTrace.compute b/Shaders/VertexAORayTrace.compute index 771a8f61..10f474e7 100644 --- a/Shaders/VertexAORayTrace.compute +++ b/Shaders/VertexAORayTrace.compute @@ -45,6 +45,7 @@ float _NormalOffset; float _MinHitDist; float _Intensity; float _CosineWeighted; +float _BinaryHit; float _FlipNormals; float _BackfaceCulling; float _GroundPlane; @@ -206,7 +207,7 @@ void BakeAO(uint3 id : SV_DispatchThreadID) if (!isBackface) { - float falloff = 1.0 - hitT / _MaxDist; + float falloff = (_BinaryHit > 0.5) ? 1.0 : (1.0 - hitT / _MaxDist); uint wOcc = (uint)(weight * falloff * FP_SCALE); wOccluded += wOcc; didOcclude = true; @@ -220,7 +221,7 @@ void BakeAO(uint3 id : SV_DispatchThreadID) float t = (_GroundY - origin.y) / jitteredDir.y; if (t > 0.0 && t < _MaxDist) { - float falloff = 1.0 - t / _MaxDist; + float falloff = (_BinaryHit > 0.5) ? 1.0 : (1.0 - t / _MaxDist); uint wOcc = (uint)(weight * falloff * FP_SCALE); wOccluded += wOcc; } diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 00000000..1b823f47 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b3a341f56c2440d5aafee409004d930f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 00000000..973276a7 --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cd835d765b7045839ab40f9b08ba905b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/SashaRX.UnityMeshLab.Tests.Editor.asmdef b/Tests/Editor/SashaRX.UnityMeshLab.Tests.Editor.asmdef new file mode 100644 index 00000000..cd06ff5f --- /dev/null +++ b/Tests/Editor/SashaRX.UnityMeshLab.Tests.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "SashaRX.UnityMeshLab.Tests.Editor", + "rootNamespace": "SashaRX.UnityMeshLab.Tests", + "references": [ + "SashaRX.UnityMeshLab.Editor", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": false, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Editor/SashaRX.UnityMeshLab.Tests.Editor.asmdef.meta b/Tests/Editor/SashaRX.UnityMeshLab.Tests.Editor.asmdef.meta new file mode 100644 index 00000000..f6f04a3a --- /dev/null +++ b/Tests/Editor/SashaRX.UnityMeshLab.Tests.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 30162b074cb04b82b3bc645bb6b7a05d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/UvShellExtractorTests.cs b/Tests/Editor/UvShellExtractorTests.cs new file mode 100644 index 00000000..cf10859b --- /dev/null +++ b/Tests/Editor/UvShellExtractorTests.cs @@ -0,0 +1,133 @@ +// UvShellExtractorTests.cs — coverage for UV-shell extraction and overlap-group detection. +// Smoke tests + the critical contract that ExtractWithOverlap correctly identifies +// stacked tile-like shells (the case that drove the group-aware repack architecture). + +using NUnit.Framework; +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class UvShellExtractorTests + { + // Build a flat quad (two triangles) at the given UV bbox. + static (Vector2[] uv, int[] tris, int vertOffset) BuildQuad(float u0, float v0, float u1, float v1, int baseVertex) + { + var uv = new Vector2[] + { + new Vector2(u0, v0), + new Vector2(u1, v0), + new Vector2(u1, v1), + new Vector2(u0, v1), + }; + var tris = new int[] + { + baseVertex + 0, baseVertex + 1, baseVertex + 2, + baseVertex + 0, baseVertex + 2, baseVertex + 3, + }; + return (uv, tris, baseVertex + 4); + } + + static (Vector2[] uv, int[] tris) Combine(params (Vector2[] uv, int[] tris, int _)[] quads) + { + var uvList = new List(); + var triList = new List(); + foreach (var q in quads) + { + uvList.AddRange(q.uv); + triList.AddRange(q.tris); + } + return (uvList.ToArray(), triList.ToArray()); + } + + [Test] + public void Extract_TwoDisjointQuads_FindsTwoShells() + { + // Two quads in entirely separate UV regions + var q1 = BuildQuad(0.0f, 0.0f, 0.3f, 0.3f, 0); + var q2 = BuildQuad(0.6f, 0.6f, 0.9f, 0.9f, q1.vertOffset); + var (uv, tris) = Combine(q1, q2); + + var shells = UvShellExtractor.Extract(uv, tris); + + Assert.AreEqual(2, shells.Count, "Disjoint UV patches should produce 2 shells"); + Assert.AreEqual(4, shells[0].vertexIndices.Count); + Assert.AreEqual(4, shells[1].vertexIndices.Count); + } + + [Test] + public void FindOverlapGroups_StackedTiles_DetectsSingleGroup() + { + // 4 quads stacked in the same UV region — the canonical tile pattern + var q1 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, 0); + var q2 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, q1.vertOffset); + var q3 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, q2.vertOffset); + var q4 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, q3.vertOffset); + var (uv, tris) = Combine(q1, q2, q3, q4); + + var shells = UvShellExtractor.Extract(uv, tris); + Assert.AreEqual(4, shells.Count, "Four topologically-disjoint stacked tiles should be 4 shells"); + + var groups = UvShellExtractor.FindOverlapGroups(shells); + Assert.AreEqual(1, groups.Count, "All four overlapping tiles should form ONE overlap group"); + Assert.AreEqual(4, groups[0].Count, "The group should contain all four tile shells"); + } + + [Test] + public void FindOverlapGroups_DisjointPlusTiles_SeparatesGroups() + { + // 3 stacked tiles + 1 isolated patch + var t1 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, 0); + var t2 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, t1.vertOffset); + var t3 = BuildQuad(0.1f, 0.1f, 0.4f, 0.4f, t2.vertOffset); + var iso = BuildQuad(0.7f, 0.7f, 0.95f, 0.95f, t3.vertOffset); + var (uv, tris) = Combine(t1, t2, t3, iso); + + var shells = UvShellExtractor.Extract(uv, tris); + Assert.AreEqual(4, shells.Count); + + var groups = UvShellExtractor.FindOverlapGroups(shells); + Assert.AreEqual(1, groups.Count, "Only the three stacked tiles form a group; the isolated patch is standalone"); + Assert.AreEqual(3, groups[0].Count); + } + + [Test] + public void CountAabbOverlaps_StackedTiles_ReturnsExpectedPairs() + { + // N stacked tiles → C(N,2) overlap pairs + const int N = 5; + var quads = new List<(Vector2[] uv, int[] tris, int _)>(); + int baseV = 0; + for (int i = 0; i < N; i++) + { + var q = BuildQuad(0.2f, 0.2f, 0.5f, 0.5f, baseV); + quads.Add(q); + baseV = q.vertOffset; + } + var (uv, tris) = Combine(quads.ToArray()); + var shells = UvShellExtractor.Extract(uv, tris); + + int pairs = UvShellExtractor.CountAabbOverlaps(shells); + Assert.AreEqual(N * (N - 1) / 2, pairs, "Stacked tiles produce all-pairs overlap count"); + } + + [Test] + public void BuildPerFaceShellIds_AssignsConsistentIdsPerShell() + { + var q1 = BuildQuad(0.0f, 0.0f, 0.3f, 0.3f, 0); + var q2 = BuildQuad(0.5f, 0.5f, 0.8f, 0.8f, q1.vertOffset); + var (uv, tris) = Combine(q1, q2); + + List shells; + List> overlapGroups; + uint[] faceShellIds = UvShellExtractor.BuildPerFaceShellIds(uv, tris, out shells, out overlapGroups); + + Assert.AreEqual(4, faceShellIds.Length, "2 quads × 2 tris each = 4 faces"); + // Faces 0/1 belong to one shell, 2/3 to the other + Assert.AreEqual(faceShellIds[0], faceShellIds[1], "Faces of the same quad must share shellId"); + Assert.AreEqual(faceShellIds[2], faceShellIds[3], "Faces of the same quad must share shellId"); + Assert.AreNotEqual(faceShellIds[0], faceShellIds[2], "Faces of different shells must have different shellIds"); + Assert.AreEqual(0, overlapGroups.Count, "Disjoint quads — no overlap groups"); + } + } +} diff --git a/Tests/Editor/UvShellExtractorTests.cs.meta b/Tests/Editor/UvShellExtractorTests.cs.meta new file mode 100644 index 00000000..7922c6cb --- /dev/null +++ b/Tests/Editor/UvShellExtractorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fa8c6212e38e4a3386c51aedfe377f6d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs b/Tests/Editor/XatlasRepackGroupMergeTests.cs new file mode 100644 index 00000000..d86c0c6f --- /dev/null +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs @@ -0,0 +1,228 @@ +// XatlasRepackTests.cs — exercises XatlasRepack.RepackSingle with synthetic +// tiled-UV0 meshes. Verifies the current pipeline contract: +// 1. Every tile-instance shell ends up with a distinct UV2 region (UV2 is +// a unique-per-shell channel for lightmap baking; the legacy +// mergeOverlappingTiles mode that shared UV2 across tiles was removed). +// 2. mesh.uv (UV0) is never mutated by the repack pipeline. +// +// The native xatlas plugin is loaded at runtime; if the DLL is missing +// (CI Linux without xatlas built), the tests are explicitly skipped. + +using NUnit.Framework; +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class XatlasRepackTests + { + static bool s_nativeAvailable; + static bool s_nativeProbed; + + static bool NativeAvailable() + { + if (s_nativeProbed) return s_nativeAvailable; + s_nativeProbed = true; + try + { + XatlasNative.xatlasCreate(); + XatlasNative.xatlasDestroy(); + s_nativeAvailable = true; + } + catch (System.DllNotFoundException) + { + s_nativeAvailable = false; + } + catch (System.EntryPointNotFoundException) + { + s_nativeAvailable = false; + } + return s_nativeAvailable; + } + + static Mesh BuildTiledMesh(int tileCount, float tileSize = 0.3f) + { + // N identical quads, each a topologically-disconnected 4-vertex patch, + // all with the same UV0 bbox (= stacked tiles in UV). + var verts = new List(); + var uvs = new List(); + var tris = new List(); + for (int t = 0; t < tileCount; t++) + { + // 3D positions translated so each tile is distinct geometry + float ox = t * 1.0f; + int v0 = verts.Count; + verts.Add(new Vector3(ox, 0, 0)); + verts.Add(new Vector3(ox + 1, 0, 0)); + verts.Add(new Vector3(ox + 1, 1, 0)); + verts.Add(new Vector3(ox, 1, 0)); + // Same UV0 for every tile + uvs.Add(new Vector2(0.1f, 0.1f)); + uvs.Add(new Vector2(0.1f + tileSize, 0.1f)); + uvs.Add(new Vector2(0.1f + tileSize, 0.1f + tileSize)); + uvs.Add(new Vector2(0.1f, 0.1f + tileSize)); + tris.AddRange(new[] { v0, v0 + 1, v0 + 2, v0, v0 + 2, v0 + 3 }); + } + var mesh = new Mesh { name = $"Tiled_{tileCount}" }; + mesh.SetVertices(verts); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(tris.ToArray(), 0); + mesh.RecalculateNormals(); + return mesh; + } + + [Test] + public void Tiles_GetDistinctUv2Regions() + { + if (!NativeAvailable()) Assert.Ignore("xatlas native plugin not available"); + + const int tileCount = 5; + var mesh = BuildTiledMesh(tileCount); + try + { + var opts = RepackOptions.Default; + opts.resolution = 512; + opts.padding = 2; + + var result = XatlasRepack.RepackSingle(mesh, opts); + Assert.IsTrue(result.ok, $"Repack failed: {result.error}"); + + var uv2 = mesh.uv2; + Assert.AreEqual(tileCount * 4, uv2.Length); + + // Each tile should land in a distinct atlas region — perturb + + // pre-pack normalisation must keep xatlas from collapsing + // identical input UVs onto the same slot. + var centroids = new Vector2[tileCount]; + for (int t = 0; t < tileCount; t++) + { + int b = t * 4; + centroids[t] = (uv2[b] + uv2[b + 1] + uv2[b + 2] + uv2[b + 3]) * 0.25f; + } + + int distinctPairs = 0; + for (int i = 0; i < tileCount; i++) + for (int j = i + 1; j < tileCount; j++) + if ((centroids[i] - centroids[j]).sqrMagnitude > 0.0001f) distinctPairs++; + + int totalPairs = tileCount * (tileCount - 1) / 2; + Assert.GreaterOrEqual(distinctPairs, totalPairs / 2, + "At least half of the tile centroids should be distinct (pipeline must not collapse tile-instances onto a shared UV2 slot)."); + } + finally + { + Object.DestroyImmediate(mesh); + } + } + + [Test] + public void RepackSingle_DoesNotModifyUv0() + { + if (!NativeAvailable()) Assert.Ignore("xatlas native plugin not available"); + + var mesh = BuildTiledMesh(3); + try + { + var uv0Before = mesh.uv; + Assert.IsNotNull(uv0Before); + + var opts = RepackOptions.Default; + opts.resolution = 256; + opts.padding = 2; + + var result = XatlasRepack.RepackSingle(mesh, opts); + Assert.IsTrue(result.ok); + + var uv0After = mesh.uv; + Assert.AreEqual(uv0Before.Length, uv0After.Length); + for (int i = 0; i < uv0Before.Length; i++) + Assert.AreEqual(uv0Before[i], uv0After[i], $"UV0 vertex {i} modified — pipeline contract violation"); + } + finally + { + Object.DestroyImmediate(mesh); + } + } + + [Test] + public void PackPreflight_DisablesBruteForce_WhenInternalOversampleIsAboveOne() + { + var method = typeof(XatlasRepack).GetMethod( + "ResolvePackBruteForce", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "XatlasRepack should expose pack preflight as a testable helper"); + + object[] args = { 1, 4, 149, (uint)1024, null }; + int resolved = (int)method.Invoke(null, args); + + Assert.AreEqual(0, resolved, + "Oversampled packs should use the xatlas heuristic packer even when the UI brute-force toggle is enabled."); + StringAssert.Contains("oversample", (string)args[4]); + } + } + + public class GroupedShellTransferTests + { + [Test] + public void Uv2PixelMargin_ScalesFromResolvedAtlasSize() + { + var method = typeof(GroupedShellTransfer).GetMethod( + "ComputeUv2PixelMargin", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "GroupedShellTransfer should scale UV2 pixel margins from the resolved atlas size"); + + object[] args = { 1389, 1360, 1.25f, 0.005f }; + float margin = (float)method.Invoke(null, args); + + Assert.AreEqual(1.25f / 1360f, margin, 1e-6f); + Assert.Less(margin, 0.005f, + "A margin tuned for a 256px atlas must shrink when the resolved atlas grows past 1k."); + } + } + + public class LightmapTransferToolUiTests + { + [Test] + public void BruteForceOption_IsUnavailable_WhenInternalOversampleIsAboveOne() + { + var method = typeof(LightmapTransferTool).GetMethod( + "IsBruteForcePackAvailable", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "LightmapTransferTool should expose the brute-force UI availability rule as a testable helper"); + + Assert.IsTrue((bool)method.Invoke(null, new object[] { 1 })); + Assert.IsTrue((bool)method.Invoke(null, new object[] { 0 })); + Assert.IsFalse((bool)method.Invoke(null, new object[] { 2 })); + Assert.IsFalse((bool)method.Invoke(null, new object[] { 4 })); + } + + [Test] + public void TransferTargetDetection_IgnoresSourceOnlySelection() + { + var method = typeof(LightmapTransferTool).GetMethod( + "HasIncludedTransferTargets", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.IsNotNull(method, "LightmapTransferTool should expose target detection as a testable helper"); + + var sourceOnly = new List + { + new MeshEntry { lodIndex = 0, include = true, originalMesh = new Mesh() } + }; + try + { + Assert.IsFalse((bool)method.Invoke(null, new object[] { sourceOnly, 0 })); + + sourceOnly.Add(new MeshEntry { lodIndex = 1, include = false, originalMesh = new Mesh() }); + Assert.IsFalse((bool)method.Invoke(null, new object[] { sourceOnly, 0 })); + + sourceOnly[1].include = true; + Assert.IsTrue((bool)method.Invoke(null, new object[] { sourceOnly, 0 })); + } + finally + { + foreach (var e in sourceOnly) + Object.DestroyImmediate(e.originalMesh); + } + } + } +} diff --git a/Tests/Editor/XatlasRepackGroupMergeTests.cs.meta b/Tests/Editor/XatlasRepackGroupMergeTests.cs.meta new file mode 100644 index 00000000..4a7ee464 --- /dev/null +++ b/Tests/Editor/XatlasRepackGroupMergeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cac79f951e7740f6b83e0c77a45c8686 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tools~/build_gallery.py b/Tools~/build_gallery.py new file mode 100644 index 00000000..1b1583f1 --- /dev/null +++ b/Tools~/build_gallery.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +""" +build_gallery.py — render an HTML voting gallery for BenchmarkReports. + +Usage: + python build_gallery.py [--out ] + +Produces: + /_gallery_index.html + /_gallery_.html (one per lodGroup) + +Each cell = (model, renderer, lodIndex, atlasRes, shellPad). The HTML page +embeds metrics + a thumbnail of the result UV2 PNG. Use the keyboard or the +mouse to score cells: + + 1..5 rate current cell (1=ugly, 3=neutral, 5=good); 0=clear + g/b/u/n shortcuts for good/bad/ugly/neutral + Space next unrated cell + Tab/→ next cell, Shift+Tab/← previous + t toggle tag panel for current cell + e export votes JSON + i import votes JSON + +Votes live in localStorage so they survive reloads. Hit "Export votes" at the +bottom to download a .json blob you can commit alongside the data. + +The script discovers the BenchmarkReports layout by looking for *.csv files +matching *_sweep_res*_pad*_bdr*_*.csv next to *_png/ folders. +""" + +import argparse, csv, glob, html, json, os, re, sys +from collections import defaultdict + + +# Timestamp prefix is yyyyMMdd_HHmmss; BenchmarkRecorder optionally appends +# _fff (milliseconds) since the collision-resistance fix, so accept either +# form. Without the optional ms group the _fff segment would otherwise be +# captured into lodGroup and split sweep cells across pseudo-models. +CELL_PATTERN = re.compile( + r"^(?P\d{8}_\d{6}(?:_\d{3})?)_(?P.+?)_sweep_res(?P\d+)_pad(?P\d+)_bdr(?P\d+)_.*?\.csv$" +) + + +def discover_cells(root): + """Return list of dicts describing every sweep cell with its CSV rows + PNG dir. + + png_dir is stored as an ABSOLUTE path. The renderer rewrites it to a path + relative to the output HTML's directory at emit time, so the gallery keeps + working when --out points outside data_dir. + """ + cells = [] + for csv_path in sorted(glob.glob(os.path.join(root, "*_sweep_*.csv"))): + m = CELL_PATTERN.match(os.path.basename(csv_path)) + if not m: + continue + png_dir = csv_path.replace(".csv", "_png") + rows = [] + with open(csv_path, newline="", encoding="utf-8-sig") as f: + for row in csv.DictReader(f): + rows.append(row) + cells.append(dict( + csv=csv_path, + png_dir=os.path.abspath(png_dir), + png_dir_exists=os.path.isdir(png_dir), + ts=m.group("ts"), + lodGroup=m.group("lodGroup"), + res=int(m.group("res")), + pad=int(m.group("pad")), + bdr=int(m.group("bdr")), + rows=rows, + modeTag=os.path.basename(csv_path).split(f"bdr{m.group('bdr')}_", 1)[-1].rsplit(".", 1)[0], + )) + return cells + + +CSS = r""" +body{background:#181820;color:#ddd;font-family:system-ui,sans-serif;margin:18px;} +a{color:#9cf;}a:visited{color:#c9c9ff;} +h1,h2{border-bottom:1px solid #333;padding-bottom:4px;} +h2{margin-top:32px;} +table{border-collapse:collapse;margin:8px 0 24px;} +th,td{border:1px solid #333;padding:4px;text-align:center;font-size:11px;vertical-align:top;background:#222;} +th{background:#222;} +td.cell{position:relative;width:200px;} +td.capHit{outline:2px solid #f80;} +td.bad{background:#3a1818;} +td.lowUtil{box-shadow:inset 0 0 0 2px #f33;} +td.empty{color:#555;} +img{display:block;width:180px;height:180px;object-fit:contain;background:#0c0c10;cursor:pointer;} +.meta{font-size:10px;color:#aaa;line-height:1.3;margin-top:2px;text-align:left;} +.legend{font-size:12px;color:#999;margin:6px 0 16px;} +.nav a{margin-right:10px;} +/* Voting controls */ +.vote-row{display:flex;justify-content:space-between;align-items:center;margin-top:3px;} +.vote-btns{display:flex;gap:2px;} +.vote-btn{cursor:pointer;width:18px;height:18px;font-size:11px;line-height:18px;border:1px solid #444;background:#1a1a1f;color:#888;text-align:center;border-radius:3px;} +.vote-btn:hover{background:#2a2a30;color:#ddd;} +.vote-btn.active{background:#3b6;color:#000;font-weight:bold;} +.vote-btn[data-rating="1"].active{background:#c33;color:#fff;} +.vote-btn[data-rating="2"].active{background:#e74;color:#fff;} +.vote-btn[data-rating="3"].active{background:#dd4;color:#000;} +.vote-btn[data-rating="4"].active{background:#9c4;color:#000;} +.vote-btn[data-rating="5"].active{background:#3b6;color:#000;} +.tag-toggle{cursor:pointer;font-size:11px;color:#888;border:1px solid #444;padding:1px 6px;border-radius:3px;background:#1a1a1f;} +.tag-toggle:hover{color:#ddd;} +.tag-panel{display:none;position:absolute;left:0;right:0;top:100%;z-index:5;background:#1a1a22;border:1px solid #555;padding:6px;margin-top:2px;text-align:left;} +.tag-panel.open{display:block;} +.tag-panel label{display:block;font-size:10px;color:#bbb;margin:1px 0;cursor:pointer;} +.tag-panel textarea{width:100%;background:#0c0c10;color:#ddd;border:1px solid #333;font-size:10px;margin-top:4px;height:32px;} +td.cell.rate-1{box-shadow:inset 0 0 0 3px #c33;} +td.cell.rate-2{box-shadow:inset 0 0 0 3px #e74;} +td.cell.rate-3{box-shadow:inset 0 0 0 3px #dd4;} +td.cell.rate-4{box-shadow:inset 0 0 0 3px #9c4;} +td.cell.rate-5{box-shadow:inset 0 0 0 3px #3b6;} +td.cell.current{outline:3px solid #59f;outline-offset:-3px;} +/* Bottom bar */ +#bar{position:sticky;bottom:0;background:#222;border-top:1px solid #444;padding:8px 12px;margin:24px -18px -18px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;} +#bar button{background:#2a2a30;color:#ddd;border:1px solid #555;padding:5px 10px;cursor:pointer;border-radius:3px;font-size:12px;} +#bar button:hover{background:#3a3a44;} +#bar .stat{font-size:12px;color:#9cf;} +#help{font-size:11px;color:#888;} +#help kbd{background:#222;border:1px solid #555;padding:0 4px;border-radius:2px;color:#ddd;font-family:monospace;} +""" + +VOTE_TAGS = [ + "narrow_strips", + "empty_atlas", + "rotation_wrong", + "stretched", + "good_pack", + "broken_shells", + "small_shells", + "overlap_visible", +] + + +JS = r""" +const VOTE_KEY = 'uvSweepVotes/' + (window.GALLERY_ID || 'default'); +const TAGS = %TAGS_JSON%; + +let votes = {}; +try { votes = JSON.parse(localStorage.getItem(VOTE_KEY) || '{}'); } catch (e) { votes = {}; } +let cells = []; +let currentIdx = -1; + +function saveVotes() { + localStorage.setItem(VOTE_KEY, JSON.stringify(votes)); + refreshStats(); +} + +function applyVoteUI(td) { + const id = td.dataset.cellId; + const v = votes[id] || {}; + td.classList.remove('rate-1','rate-2','rate-3','rate-4','rate-5'); + if (v.rating) td.classList.add('rate-' + v.rating); + td.querySelectorAll('.vote-btn').forEach(b => { + b.classList.toggle('active', String(v.rating || 0) === b.dataset.rating); + }); + const panel = td.querySelector('.tag-panel'); + if (panel) { + panel.querySelectorAll('input[type=checkbox]').forEach(cb => { + cb.checked = (v.tags || []).includes(cb.value); + }); + const note = panel.querySelector('textarea'); + if (note) note.value = v.note || ''; + } +} + +function setRating(id, r) { + if (!votes[id]) votes[id] = {}; + if (r === 0) { + delete votes[id].rating; + if (!votes[id].rating && !(votes[id].tags||[]).length && !votes[id].note) delete votes[id]; + } else { + votes[id].rating = r; + } + saveVotes(); + document.querySelectorAll('td.cell').forEach(applyVoteUI); +} + +function toggleTag(id, tag) { + if (!votes[id]) votes[id] = {}; + const t = votes[id].tags || []; + const i = t.indexOf(tag); + if (i >= 0) t.splice(i, 1); else t.push(tag); + votes[id].tags = t; + if (!t.length) delete votes[id].tags; + if (!votes[id].rating && !(votes[id].tags||[]).length && !votes[id].note) delete votes[id]; + saveVotes(); +} + +function setNote(id, note) { + if (!votes[id]) votes[id] = {}; + if (note.trim()) votes[id].note = note; + else { delete votes[id].note; + if (!votes[id].rating && !(votes[id].tags||[]).length) delete votes[id]; } + saveVotes(); +} + +function setCurrent(idx) { + if (currentIdx >= 0 && cells[currentIdx]) cells[currentIdx].classList.remove('current'); + currentIdx = (idx + cells.length) % cells.length; + cells[currentIdx].classList.add('current'); + cells[currentIdx].scrollIntoView({block:'center', behavior:'smooth'}); +} + +function nextUnrated() { + for (let i = 1; i <= cells.length; i++) { + const j = (currentIdx + i) % cells.length; + const id = cells[j].dataset.cellId; + if (!votes[id] || !votes[id].rating) { setCurrent(j); return; } + } + alert('All cells rated.'); +} + +function refreshStats() { + const total = cells.length; + const rated = cells.filter(td => votes[td.dataset.cellId] && votes[td.dataset.cellId].rating).length; + const stat = document.getElementById('stat'); + if (stat) stat.textContent = `${rated} / ${total} rated`; +} + +function exportVotes() { + const blob = new Blob([JSON.stringify({galleryId: window.GALLERY_ID, votes}, null, 2)], + {type:'application/json'}); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'votes_' + (window.GALLERY_ID || 'gallery') + '.json'; + a.click(); +} + +function importVotes(file) { + const reader = new FileReader(); + reader.onload = e => { + try { + const data = JSON.parse(e.target.result); + const incoming = data.votes || data; + Object.assign(votes, incoming); + saveVotes(); + document.querySelectorAll('td.cell').forEach(applyVoteUI); + } catch (err) { alert('Bad JSON: ' + err.message); } + }; + reader.readAsText(file); +} + +function clearAll() { + if (!confirm('Clear all votes for this gallery?')) return; + votes = {}; + saveVotes(); + document.querySelectorAll('td.cell').forEach(applyVoteUI); +} + +document.addEventListener('DOMContentLoaded', () => { + cells = Array.from(document.querySelectorAll('td.cell')); + cells.forEach((td, i) => { + applyVoteUI(td); + td.addEventListener('click', e => { + if (e.target.tagName === 'IMG' || e.target.tagName === 'A') return; + setCurrent(i); + }); + td.querySelectorAll('.vote-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + setCurrent(i); + setRating(td.dataset.cellId, parseInt(btn.dataset.rating, 10)); + }); + }); + const tagToggle = td.querySelector('.tag-toggle'); + if (tagToggle) tagToggle.addEventListener('click', e => { + e.stopPropagation(); + const panel = td.querySelector('.tag-panel'); + if (panel) panel.classList.toggle('open'); + }); + td.querySelectorAll('.tag-panel input[type=checkbox]').forEach(cb => { + cb.addEventListener('change', e => { + e.stopPropagation(); + toggleTag(td.dataset.cellId, cb.value); + }); + }); + const note = td.querySelector('.tag-panel textarea'); + if (note) note.addEventListener('input', e => setNote(td.dataset.cellId, e.target.value)); + }); + if (cells.length) setCurrent(0); + refreshStats(); + document.addEventListener('keydown', e => { + if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return; + if (currentIdx < 0) return; + const id = cells[currentIdx].dataset.cellId; + const k = e.key.toLowerCase(); + if (/^[0-5]$/.test(k)) { setRating(id, parseInt(k, 10)); } + else if (k === 'g') { setRating(id, 4); } + else if (k === 'b') { setRating(id, 2); } + else if (k === 'u') { setRating(id, 1); } + else if (k === 'n') { setRating(id, 3); } + else if (k === ' ' || k === 'spacebar') { e.preventDefault(); nextUnrated(); } + else if (k === 'tab') { e.preventDefault(); setCurrent(e.shiftKey ? currentIdx-1 : currentIdx+1); } + else if (k === 'arrowright') { e.preventDefault(); setCurrent(currentIdx+1); } + else if (k === 'arrowleft') { e.preventDefault(); setCurrent(currentIdx-1); } + else if (k === 't') { + const panel = cells[currentIdx].querySelector('.tag-panel'); + if (panel) panel.classList.toggle('open'); + } + else if (k === 'e') exportVotes(); + }); + const fileIn = document.getElementById('file-import'); + if (fileIn) fileIn.addEventListener('change', e => { if (e.target.files[0]) importVotes(e.target.files[0]); }); +}); +""" + + +def cell_html(cell, model, renderer, lod, res_axis, pad_axis, bdr_axis, out_dir): + """Return HTML rows for one (renderer, lod) — one row per atlasRes. + + out_dir is where the .html lives; PNG hrefs are written relative to it so + galleries keep working when --out points outside the data directory. + The lookup key carries borderPad so sweeps with multiple bdr values don't + collapse cells onto each other. + """ + lookup = {} + for c in cell.values(): + for r in c["rows"]: + if r.get("isSourceLod") == "1": + continue + if r.get("rendererName") != renderer or int(r.get("lodIndex", -1)) != lod: + continue + key = (int(c["res"]), int(c["pad"]), int(c["bdr"])) + lookup[key] = (c, r) + + out = [] + bdr_list = list(bdr_axis) if bdr_axis else [0] + for res in res_axis: + for bdr_idx, bdr in enumerate(bdr_list): + label = f"res={res}" + (f" bdr={bdr}" if len(bdr_list) > 1 else "") + out.append(f'{label}') + for pad in pad_axis: + entry = lookup.get((res, pad, bdr)) + if not entry: + out.append('—') + continue + c, r = entry + cell_id = f"{model}|{renderer}|lod{lod}|res{res}|pad{pad}|bdr{bdr}" + inv = int(float(r.get("invertedCount", 0))) + stt = int(float(r.get("stretchedCount", 0))) + za = int(float(r.get("zeroAreaCount", 0))) + tex = float(r.get("texelDensityMedian", 0)) + topfx = int(float(r.get("topologyFixed", 0))) + cap = r.get("topologyCapHit", "False").lower() in ("1","true") + try: util = float(r.get("atlasUtilization", 0) or 0) + except: util = 0.0 + png_name = f"{r.get('rendererName')}_LOD{lod}_uv2.png" + full_png = os.path.join(c["png_dir"], png_name) + # Absolute PNG path -> path relative to the gallery HTML directory + try: + png_path = os.path.relpath(full_png, out_dir).replace("\\", "/") + except ValueError: + png_path = full_png + img_html = (f'' + f'') \ + if os.path.exists(full_png) else 'no png' + cls = ["cell"] + if cap: cls.append("capHit") + if za > 100: cls.append("bad") + tag_panel = '
' + for t in VOTE_TAGS: + tag_panel += f'' + tag_panel += '
' + vote_buttons = ''.join( + f'{rb}' + for rb in (1,2,3,4,5) + ) + util_label = (f'util={util*100:.0f}%' if util > 0 else 'util=?') + if util > 0 and util < 0.5: cls.append('lowUtil') + out.append( + f'' + f'{img_html}' + f'
inv={inv} str={stt} 0A={za} {util_label}
tex={tex:.0f} topFx={topfx}{"⛔" if cap else ""}
' + f'
' + f'
{vote_buttons}
' + f' tags' + f'
' + f'{tag_panel}' + f'' + ) + out.append('') + return "".join(out) + + +def render_index(out_dir, models, gallery_id): + parts = [f'UV2 sweep gallery', + f'', + f'

UV2 sweep gallery — {html.escape(gallery_id)}

', + '

Per-model galleries with voting (rating 1-5 + tags). Votes live in your browser; export at the bottom of each page.

', + '') + with open(os.path.join(out_dir, "_gallery_index.html"), "w", encoding="utf-8") as f: + f.write("\n".join(parts)) + + +def render_model(out_dir, gallery_id, model, all_cells, res_axis, pad_axis, bdr_axis): + cells_for_model = [c for c in all_cells if c["lodGroup"] == model] + # Key carries borderPad so sweeps with multiple bdr values don't collide. + cells_by_key = {(c["res"], c["pad"], c["bdr"]): c for c in cells_for_model} + + # collect (renderer, lod) pairs + pairs = set() + for c in cells_for_model: + for r in c["rows"]: + if r.get("isSourceLod") == "1": + continue + pairs.add((r.get("rendererName"), int(r.get("lodIndex", -1)))) + pairs = sorted(pairs) + + nav = " | ".join(f'{html.escape(m)}' for m in MODELS_ORDER) + nav += f' | index' + + parts = [f'UV2 — {html.escape(model)}', + f'', + f'', + f'

UV2 sweep gallery — {html.escape(model)} ({html.escape(gallery_id)})

', + f'', + '
Columns = shellPad, rows = atlasRes. ' + 'Click a thumbnail for full-size. Use the 1-5 buttons or hotkeys. ' + '1-5 rate · g/b/u/n shortcuts · ' + 'Space next unrated · Tab/ next · t tags · e export' + '
'] + + for renderer, lod in pairs: + parts.append(f'

{html.escape(renderer)} / LOD{lod}

') + parts.append('') + for p in pad_axis: parts.append(f'') + parts.append('') + parts.append(cell_html(cells_by_key, model, renderer, lod, res_axis, pad_axis, bdr_axis, out_dir)) + parts.append('
res \\ padpad={p}
') + + parts.append('
' + '' + '' + '' + '' + 'Hotkeys: 1-5/g/b/u/n rate · Space=next unrated · Tab next · t=tags · e=export' + '
') + + js_inline = JS.replace("%TAGS_JSON%", json.dumps(VOTE_TAGS)) + parts.append(f'') + parts.append('') + + with open(os.path.join(out_dir, f"_gallery_{model}.html"), "w", encoding="utf-8") as f: + f.write("\n".join(parts)) + + +MODELS_ORDER = [] # filled per call + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("data_dir", help="BenchmarkReports folder containing *_sweep_*.csv") + ap.add_argument("--out", default=None, help="output directory (default = data_dir)") + ap.add_argument("--gallery-id", default=None, help="identifier (also localStorage key)") + args = ap.parse_args() + + if not os.path.isdir(args.data_dir): + print(f"Not a directory: {args.data_dir}", file=sys.stderr) + sys.exit(1) + out_dir = args.out or args.data_dir + os.makedirs(out_dir, exist_ok=True) + gallery_id = args.gallery_id or os.path.basename(os.path.normpath(args.data_dir)) + + cells = discover_cells(args.data_dir) + if not cells: + print("No *_sweep_*.csv found.", file=sys.stderr) + sys.exit(1) + + res_axis = sorted({c["res"] for c in cells}) + pad_axis = sorted({c["pad"] for c in cells}) + bdr_axis = sorted({c["bdr"] for c in cells}) + models = sorted({c["lodGroup"] for c in cells}) + + global MODELS_ORDER + MODELS_ORDER = models + + for m in models: + render_model(out_dir, gallery_id, m, cells, res_axis, pad_axis, bdr_axis) + render_index(out_dir, models, gallery_id) + + print(f"Wrote galleries → {out_dir}") + print(f"Models: {models}") + print(f"Cells: {len(cells)}") + print(f"Open: {os.path.join(out_dir, '_gallery_index.html')}") + + +if __name__ == "__main__": + main() diff --git a/Tools~/gen.bat b/Tools~/gen.bat new file mode 100644 index 00000000..05dadc57 --- /dev/null +++ b/Tools~/gen.bat @@ -0,0 +1,18 @@ +@echo off +rem gen.bat — wrapper around the gallery-builder script. +rem Avoids typing the .py extension in chat clients that auto-link it. +rem +rem Usage: +rem Tools\gen.bat "" [--gallery-id ""] +rem +rem Example: +rem Tools\gen.bat "_results~/noSymSplit_2026-04-28" --gallery-id "noSymSplit_2026-04-28" + +setlocal +set SCRIPT=%~dp0build_gallery.py +if not exist "%SCRIPT%" ( + echo [gen.bat] Cannot find %SCRIPT% + exit /b 1 +) +python "%SCRIPT%" %* +endlocal diff --git a/package.json b/package.json index 9ed0424f..f707f5e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.sasharx.unitymeshlab", - "version": "1.0.2", + "version": "1.0.7", "displayName": "Mesh Lab", "description": "Unity Editor tool suite: UV2 lightmap transfer, LOD generation, UV analysis, and FBX export with binary format support.", "unity": "6000.0",