diff --git a/CHANGELOG.md b/CHANGELOG.md index f03ed638..c294ce75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,12 +26,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Assets ▸ Create context menu: `Mesh Lab ▸ Sweep Test Suite` (was always-visible `Lightmap UV Tool/Test Suite` via `[CreateAssetMenu]`). - **In-flight gate + `FireAndForget` helper** for fire-and-forget async UI actions. The "Run Full Pipeline" / "Run Repack only" / "Run Transfer only" / "Repack All" / "Transfer All Targets" buttons now sit inside an `EditorGUI.DisabledScope` on `_pipelineInFlight` so a second click can't launch an interleaving run; `FireAndForget` attaches `ContinueWith` on the Unity sync context to log Task faults through `UvtLog.Error`, release the gate, and call `UvProgress.Fail` so a thrown exception can't leave the strip stuck on a stale phase. - **Cancel-aware benchmark recording** in `ExecTransferAllImpl` — mirrors the `completedSuccessfully` guard `ExecFullPipelineImpl` already uses. Cancelled transfers no longer emit stale `shellTransferResult` / validation rows that taint sweep aggregates. +- **Solid Color bake mode.** Fills `mesh.colors32` with a uniform `Color32` across every mesh variant of an entry (`originalMesh`, `repackedMesh`, `transferredMesh`, `fbxMesh`). Collision meshes are skipped via `MeshHygieneUtility.IsCollisionNodeName`. Each write goes through `Undo.RecordObject` + `EditorUtility.SetDirty`. +- **Batch variant export pipeline.** New `VariantExportPipeline` (`Editor/Tools/VariantExportPipeline.cs`) drives a list of `(Color, suffix)` variants against a source FBX and an optional source prefab. Per variant it paints, exports `{base}_{suffix}.fbx`, instantiates the source prefab, unpacks it (full clone, not a Prefab Variant), swaps `MeshFilter.sharedMesh` to the matching new sub-mesh by name, and saves `{base}_{suffix}.prefab`. Each variant completes its own export → import → clone cycle, with a single `AssetDatabase.Refresh` at the end. Suffixes are validated against `^[A-Za-z0-9_]+$` and duplicates inside a batch are rejected upfront. Conflicts are overwritten — git is the rollback path. +- **`LightmapTransferTool.ExportVertexColorsToFbxAs(sourceFbxPath, outputFbxPath, entries, uvChannelOverride)`** — public entry point that writes a new FBX next to (or anywhere relative to) the source without mutating the source importer, scene mesh bindings, or working copies. ### Changed +- **Vertex AO tab renamed to Vertex Color Baking.** `VertexAOTool` → `VertexColorBakingTool`, `ToolId` `vertex_ao` → `vertex_color_baking`. Asset GUID preserved so existing references stay intact. AO functionality is unchanged and reachable via the new toolbar at the top of the tab. - **Default `RepackResolutionMode` is now `AutoFromTexelDensity`** (was `Manual`). Uniform real-world texels-per-meter is the desired outcome for lightmaps; the previous fixed-resolution default produced wildly different texel density per asset depending on world size. - **xatlas pack no longer raises a modal progress dialog** (`EditorUtility.DisplayCancelableProgressBar`). Progress now flows through `UvProgress` to the Background Tasks panel and the inline strip; cancel via `UvProgress.CancelRequested`. - **Hoisted all late `sourceMesh.*` and `targetMesh.*` reads** in `GroupedShellTransfer.Transfer` up to the initial Mesh-data extraction block. Keeps the algorithm body Unity-API-free so it runs cleanly in `Task.Run`. +### 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. + ### Removed - All `EditorUtility.DisplayProgressBar` / `DisplayCancelableProgressBar` / `ClearProgressBar` calls. Replaced with `UvProgress` everywhere (XatlasRepack, LightmapTransferTool, VertexAOBaker.Cpu, FbxMetricsExporter, LodGenerationTool). - `[CreateAssetMenu(menuName = "Lightmap UV Tool/Test Suite")]` from `TestSuiteAsset` — replaced with a gated `[MenuItem("Assets/Create/Mesh Lab/Sweep Test Suite")]`. diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 54ed2b86..f317e5c2 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -264,6 +264,53 @@ - `FixMeshStripUvs()` — vertex colors `(0,0,0,0)` может быть валидное AO (полная окклюзия). Авто-стрип colors убран. - `SaveAndReimport()` в cleanup — добавлять `bypassPaths` чтобы постпроцессор не вмешался. +--- + +## External MeshLab (desktop) — Settings to Avoid Artifacts + +> Обновлено: 2026-05-02 +> +> Применимо к FBX, прошедшим через десктопный **MeshLab** (https://www.meshlab.net/) перед импортом в Unity. Не путать с пакетом **UnityMeshLab** — это про внешний инструмент. +> +> Программная проверка симптомов: `MeshLabArtifactValidator` (`Editor/MeshLabArtifactValidator.cs`), +> menu **Tools → Mesh Lab → Validators → Check Imported MeshLab Artifacts** (по выделенному GameObject/asset). + +### Filters — что ломает геометрию + +1. **Merge Close Vertices** — `Distance` ставить очень маленьким (`1e-6`) или отключать filter. + - На дефолтном threshold склеивает per-wedge UV-вершины в одну → **UV seams разъезжаются**, текстура «плывёт» по швам. + - Симптом в Unity: `mesh.vertexCount == уникальных позиций`, при том что в сцене заметны видимые швы. + +2. **Parameterization (UV1)** — выбирать filter с поддержкой существующих UV seams: `Trivial Per-Triangle Parameterization` или `Iso Parametrization`. + - Дефолтный `Parameterization: Flat Plane` ломает развёртку UV1 (весь mesh проецируется в одну плоскость). + - Симптом: UV1 — degenerate triangles или всё в `(0,0)`. + +3. **Remove Duplicate Faces / Remove Duplicate Vertices** — отключать, если есть split UVs. + - В 3ds Max split UVs выглядят как «дубликаты» по позиции (но имеют разные UV/normal). + - MeshLab их склеивает → теряются UV seams и smoothing groups. + +### Export FBX dialog — обязательные настройки + +| Опция | Значение | Зачем | +|-------|----------|-------| +| `WedgeAllChannels` | **ON** | Сохраняет per-wedge UV / normal (т.е. split UVs / smoothing seams). | +| `UseExistingNormals` | **ON** | Не пересчитывает нормали (иначе теряются smoothing groups → flat shading). | + +### Симптомы плохих настроек в Unity (что ловит валидатор) + +- **No UV seams**: `vertexCount` ≈ числу уникальных позиций при наличии нескольких UV-островов → `Merge Close Vertices` или `Remove Duplicate Vertices` склеили wedge. +- **Flat-shaded normals**: > 90% треугольников имеют 3 одинаковых vertex-нормали, совпадающих с face-normal → нормали пересчитаны без сохранения smoothing. +- **Degenerate UV1**: > 5% треугольников UV1 с нулевой площадью — broken parameterization. +- **UV1 all zero**: все UV1 в `(0,0)` — MeshLab стёр UV1 канал. +- **UV1 out of unit**: > 5% UV1-вершин вне `[0..1]` — Flat Plane / Iso без нормализации. + +### Проверка + +Перед импортом FBX из MeshLab в Unity: +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. diff --git a/Documentation~/FBX_PIPELINE_CHECKLIST.md b/Documentation~/FBX_PIPELINE_CHECKLIST.md new file mode 100644 index 00000000..f9de2e4d --- /dev/null +++ b/Documentation~/FBX_PIPELINE_CHECKLIST.md @@ -0,0 +1,324 @@ +# FBX pipeline rules + +Regression checklist for FBX authoring + tooling. Each violation +maps to a category in `patch_fbx_materials --report-file` and / or +a Unity import warning. Better to not create the violation than to +patch it after the fact. + +This document is the source of truth — `patch_fbx_materials` only +exists to catch what slipped through. + +## 1. Materials + +| Rule | Why | +|---|---| +| Class is `FbxSurfacePhong`, not Lambert / generic `FbxSurfaceMaterial` | Max FBX importer maps Phong → Physical Material BaseColor reliably; Lambert loses the diffuse-texture link during auto-conversion | +| `sDiffuse` is wired to a real `FbxFileTexture` (not just RGBA color) | Without an attached texture Unity renders the material pink | +| Both `FbxFileTexture::SetFileName()` (absolute) and `SetRelativeFileName()` populated | Absolute paths break on every other machine; relative is the fallback | +| Material name `M_`; texture name `T__` (`_Albedo` / `_BaseColor` / `_AlbedoTransparency` / …) | `--guess-textures` only resolves textures that follow this convention | +| **No placeholder materials** (`Lit`, `Default`, `Material`, empty name) | Unity surfaces them as `None (Material)` in the Remapped Materials list | +| No `FbxLayerElementMaterial` with `mapping=eNone` on layer 0 | Max reads layer 0 first and treats `eNone` as "no material", silently dropping the assignment | +| `FbxFileTexture` object name = basename of file (no extension) | Max FBX importer rewrites every texture name to a generic channel label (`DiffuseColor_Texture` etc.) — losing the human-readable identity | +| `FbxNode::SetShadingMode(eTextureShading)` on every node with materials | Without it Unity renders the mesh unlit / wireframe | + +## 2. Triangulation + +| Rule | Why | +|---|---| +| `FbxGeometryConverter::Triangulate` only on **static** meshes (no `FbxSkin` / `FbxBlendShape` / vertex cache) | Triangulation reorders polygon-vertex indices; deformers store CP indices, mismatch → broken skin/blendshape | +| After triangulation, `FbxLayerElementMaterial::mapping=ByPolygon` must remain `ByPolygon` (not collapse to `eAllSame`) | SDK occasionally loses mapping mode → Unity sees "no material" | +| No degenerate polygons: 3 collinear control points, duplicate vertex indices in the same polygon, or polygon size < 3 | Source of black flickering and lightmap artifacts in Unity | +| Run `mesh->BuildMeshEdgeArray()` after retriangulation if you ship smoothing groups | Max reads edge data for smoothing | + +## 3. Layers — UV / vcolor / normals / tangents + +| Rule | Why | +|---|---| +| **Never clamp UV0** | Destroys every tiled-texture pattern | +| UV1 (lightmap) clamped to `[0, 1]` | UV1 outside range breaks lightmap pack | +| UV2 — clamp X to `[0, 1]`, Y per project convention (this codebase: 0) | UV2 is metadata (instance-index / wind / scaler) | +| No empty `FbxLayerElementUV` / `FbxLayerElementVertexColor` slots (eDirect with size 0, or eIndexToDirect with mismatched index array) | Unity / Max surface this as "Invalid UV index table" / "[LayerElement] Bad number of elements in array" | +| Each layer-element's `mapping mode` matches array size: `eByControlPoint→cpCount`, `eByPolygonVertex→polyVtxCount`, `eByPolygon→polyCount`, `eAllSame→1` | Mismatch means readers stuff arbitrary values into the gaps | +| Diffuse-texture `UVSet` name = name of `mesh->GetElementUV(0)` | Without it Max drops the texture-mesh binding (UV resolution fails) | +| `_COL` meshes (node name suffix `_COL`, case-insensitive) ship **no UV channels and no vcolor layers** | Collision meshes never render — pure dead weight | +| One vcolor layer per mesh on layer 0 | Multiple vcolor layers surface as unnamed map channels (4:map, 5:map…) in Max and confuse material setup | +| `FbxLayerElementNormal::mapping = eByPolygonVertex` if the mesh has smoothing groups, `eByControlPoint` if not | Max reads the two cases differently | + +## 4. Vertex colors + +| Rule | Why | +|---|---| +| If your shader reads `mesh.color`, ship a vcolor layer | Without one Unity reads (0, 0, 0, 0) → every multiply collapses to black | +| RGBA in `[0, 1]` — clamp at export | Unity doesn't validate; out-of-range values break shaders silently | +| One vcolor layer (layer 0) | See §3 — extras leak into Max as map channels | + +## 5. Nodes / hierarchy + +| Rule | Why | +|---|---| +| `FbxNull` is fine for dummy / pivot helper / hinge anchor | Unity HingeJoint in a prefab references the anchor by node name — don't rename or strip these | +| `FbxSkeleton` only when there's an actual skin attached | Orphan skeleton bones are pure overhead | +| No `FbxCamera` / `FbxLight` / NURBS / patches / IK effectors in game assets | Unity ignores them; they bloat the node tree | +| `_COL` suffix on collision meshes is required (case-insensitive) | The convention is what the patcher / Unity-side scripts key off of | +| **Never use generic mesh-attribute names** (`Scene`, `Geometry`, `Default`, empty) | Max FBX importer auto-resets every mesh attribute to `Scene` on round-trip; on the export side write `mesh->SetName(node->GetName())` so Unity sees stable identifiers | +| Empty leaf dummies (`FbxNull`, no children, identity transform) — strip before export | Junk left behind by XForm operations / hierarchy edits | +| Hidden nodes — strip *or* mark `_COL` (collision conventionally hides them) | Strip-hidden tooling defaults assume `_COL` is the only legitimate hidden case | + +## 6. Skin / Bones + +| Rule | Why | +|---|---| +| **Never reorder or remove control points** on a mesh with `mesh->GetDeformerCount() > 0` | Cluster stores CP indices; renumber → broken skin upload in Unity ("Skinned mesh VBO size does not match skin data") | +| Bones (`cluster->GetLink()`) must exist in the scene at export time | Orphan cluster = broken skinned mesh | +| `LclScaling` on bone nodes AND their ancestors stays as authored | The cluster's stored bind transform (`TransformLink`/`Transform`) is computed against the original world matrix; reset → mesh deforms in the wrong frame (huge mesh on tiny skeleton) | +| `Geometric{Translation,Rotation,Scaling}` on skinned meshes = identity | Geometric* applies before LclTransform and isn't propagated to children — guaranteed to break the rig | +| `BlendShape` target CP count == base mesh CP count | Mismatch → broken blendshape | + +## 7. Scene-level + +| Rule | Why | +|---|---| +| `FbxDocumentInfo` populated: `Original_ApplicationName/Vendor` AND `LastSaved_ApplicationName/Vendor` | Empty SceneInfo is the strongest signal a metadata-stripping tool re-saved the file. `--detect-modified` flags this | +| Scene unit = meters (or call `FbxSystemUnit::m.ConvertScene` at export) | Unity Scale Factor 0.01 breaks physics, prefab overrides, lightmap-scale, batching | +| After `ConvertScene`: bake the compensating LclScaling into mesh CPs | SDK puts `0.01` on root child — Unity sees it but downstream batching / physics doesn't | +| **Embed Media OFF** | Unity duplicates extracted textures into a temp dir on every reimport | +| No `FbxAnimStack`/`FbxAnimLayer` on static meshes | Max FBX exporter ships a default "Take 001" with zero-curve layers — pure overhead | +| No orphan vertices (CPs not referenced by any polygon) | They bloat the count, push the AABB outward, break Unity bounds + lightmap-scale heuristics | +| No degenerate polygons | Lighting glitches, lightmap artifacts | +| No nodes with negative-determinant accumulated scale | Unity reads inverted normals as backface-culled → mesh appears transparent from the front | + +## 8. Naming + +| Rule | Why | +|---|---| +| **ASCII only** in node / mesh / material / texture / layer names | Cyrillic / CJK / Hiragana / Katakana / Hangul break Unity Addressables, asset bundles, filesystem-naming rules | +| No Windows-illegal characters: `< > : " / \ | ? *` | Path errors | +| No ASCII control codes (< 32, == 127), no leading/trailing spaces or dots | Path / serialization errors | +| Texture file paths must NOT contain machine-specific roots (`E:\Evegoplayon\…`, `D:\Users\MAD\Downloads\Telegram Desktop\…`) | Dangling reference on every other machine. Use relative paths or a project-rooted absolute | + +--- + +## 9. Tooling — what to use, what to avoid + +### UnityMeshLab (this project's own tool) + +[https://github.com/SashaRX/UnityMeshLab](https://github.com/SashaRX/UnityMeshLab) — a Unity Editor package +that exposes UV2 Transfer / Atlas Pack / UV0 Optimize / LOD Gen / +Collision (V-HACD) / Vertex AO via **Tools → Mesh Lab**. Output goes +through Unity FBX Exporter (`ModelExporter.ExportObjects`). + +This means UnityMeshLab outputs are detected by +`patch_fbx_materials --detect-unity-modified` (creator = +`Unity FBX Exporter`). That category in the report is **not a +warning** for files that legitimately came from this tool — it's a +provenance marker. + +**Compatibility rules with `patch_fbx_materials`:** + +* UnityMeshLab maintains hierarchy nodes named `_LOD0` / `_LOD1` / + `_COL`. Don't run the patcher with `--strip-non-mesh-nodes` or + `--strip-empty-dummies` on UnityMeshLab outputs **before** + Unity has reimported them — those passes can prune the + scaffolding the postprocessor expects to see. +* UnityMeshLab's UV2 layouts are atlas-packed and may legitimately + contain values **outside `[0, 1]`** when the packer overflows + intentionally. Do not run `--clamp-uv2` on UnityMeshLab outputs + unless you explicitly want to flatten the atlas. +* `_uv2data.asset` sidecars (next to each FBX) are the source of + truth for UV2 + collision metadata across reimports. Don't + delete them. The patcher doesn't touch `.asset` files. +* Vertex AO data is shipped as vcolor on layer 0. `--clamp-vcolor` + is safe (data is already in `[0, 1]`); `--strip-extra-vcolor- + layers` is safe (only one layer is generated). Do **not** run + `--fill-missing-vcolor` on a UnityMeshLab output — the layer is + already there with real data, the fill would be a no-op anyway. +* Run `patch_fbx_materials` AFTER UnityMeshLab, not in parallel. + The patcher is a postprocess for cleanup of legacy / external + FBX files; UnityMeshLab outputs are already clean for everything + it cares about. + +### TS_ plugin + +A custom 3ds Max FBX rewriter (the `TS_UnityExport_SDK` plugin +this repo ships, plus its older predecessors). Round-tripping an +FBX through it can drop: + +* `FbxDocumentInfo` (empty SceneInfo) — `--detect-modified` flags it. +* Vertex colors on edge cases. +* Custom material setups where a non-Phong class is involved. + +The **current** version (TS_UnityExport_SDK Phase ≥3) is +non-destructive for the categories above; the in-the-wild files +that flag `EMPTY-SCENEINFO` come from older revisions. Reexport +from the original Max source if possible. If not, the patcher's +`--rebind-missing-textures` + `--restore-mesh-names-from-node` + +`--restore-texture-names-from-file` recover what they can. + +### MeshLab (open-source desktop, NOT UnityMeshLab) + +Different tool — `[meshlab.net](http://meshlab.net)`. **Do not use for FBX round-trip.** +It is fundamentally an `.obj` / `.ply` decimation tool with weak +FBX writers. Round-tripping through MeshLab will: + +* Drop `FbxDocumentInfo` entirely. +* Leave orphan control points (decimation / welding residue). +* Create degenerate triangles (zero-area). +* Strip materials to Lambert or remove them. +* Invalidate skin / blendshape (CP renumber). +* Strip every `FbxAnimStack`. +* Reset mesh attribute names to `Scene` / `Geometry`. + +If you need decimation, use **Max ProOptimizer / MultiRes** or +**Blender Decimate Modifier**. If you need geometry cleanup, use +**Max Edit Poly → Vertex Weld / Cap Holes** or **Blender Mesh → +Clean Up**. + +### Unity FBX Exporter (used by UnityMeshLab internally) + +Generally fine, but be aware that: + +* Vertex colors are sometimes silently dropped (Unity-side bug). +* Custom-property bloat — every Unity GameObject component + serializes string properties on the FBX node. +* Animation curves are quantized — not byte-exact round-trip. +* `Original_ApplicationName='Unity FBX Exporter'` — flagged as + `UNITY-FBX` in the patcher report. + +Use it only when you have a concrete reason to (UnityMeshLab +operations, prefab-to-FBX export). Never do +Unity-FBX-export → Unity-import → Unity-FBX-export → … cycles — +each round adds quantization error and custom-property cruft. + +--- + +## 10. What `patch_fbx_materials` catches and how to read the report + +Each category in `--report-file` output corresponds to a violation +of the rules above: + +| Report category | Rule violated | How to fix at source | +|---|---|---| +| `EMPTY-SCENEINFO` | §7.1 | Reexport from Max / Blender; never round-trip through MeshLab desktop | +| `UNITY-FBX` | §9 (informational, not always a defect) | Provenance marker — file came from Unity FBX Exporter (often UnityMeshLab) | +| `CYRILLIC` / `CJK` / `ILLEGAL-CHARS` / `CONTROL-CHARS` | §8 | Rename in source DCC, reexport | +| `NEGATIVE-SCALE` | §7.8 | Reset XForm in Max, or apply transform in Blender, before export | +| `BROKEN-ALBEDO` | §1.3 + dangling-path texture refs | The source FBX has texture paths from another machine; reexport with relative paths or fix paths in Max Material Editor | +| `UNRESOLVED-ALBEDO` | §1.4 | Material name doesn't follow `M_` / texture isn't `T__` — rename in Max or set up `--texture-root` to point at a richer index | + +Unity console warnings → rules: + +| Unity warning | Rule violated | +|---|---| +| "Skinned mesh VBO size does not match skin data" | §6.1 / §6.3 / §6.4 | +| "Invalid UV index table" / "[LayerElement] Bad number of elements in array" | §3.5 | +| Pink "missing material" | §1 | +| `Scale Factor 0.01` warning on import | §7.2 | +| Black mesh where shader expects vcolor | §4.1 | +| Mesh transparent from front (backface-culled) | §7.8 | + +--- + +## 11. Authoring checklist (for new export paths in tooling) + +Before merging any code that writes FBX: + +1. `FbxDocumentInfo`: write Original_/LastSaved_ ApplicationName + Vendor. +2. Scene unit: write meters (or call `ConvertScene` and bake). +3. Materials: only `FbxSurfacePhong`, populate `sDiffuse`, attach `FbxFileTexture` with both abs and relative paths. +4. Layer elements: every UV / vcolor / normal layer has its array size matching its mapping mode. Empty layers are not shipped. +5. `_COL` meshes: no UV / no vcolor / no material if rendering-disabled. +6. Mesh names: copy from owning node, never `Scene` / `Geometry` / empty. +7. Skin / blendshape integrity: run `mesh->GetDeformerCount()` test; if non-zero, do not call any CP-mutating op. +8. Names: ASCII-only. +9. No `Take 001` empty animation stacks on static exports. +10. No `Embed Media`. + +If the tool produces a file that flags ANY category in +`patch_fbx_materials --report-file`, the tool is wrong, not the +patcher. The patcher is the regression net, not the authoring +contract. + +--- + +## 12. Isolated re-save (the only sanctioned in-tool path) + +Every FBX-writing path in UnityMeshLab MUST go through the +single isolated-export core in +`Editor/Tools/LightmapTransferTool.cs`. There is no separate +"safe" pipeline parallel to the destructive one — the core +itself is the safe path, and "destructive" operations are +expressed as a wider `FbxExportIntent`. + +### The contract + +A re-save mutates ONLY the per-vertex channels listed in the +caller's `FbxExportIntent`. Everything else — node names, +hierarchy, transforms, material assignments, untouched UV +channels, vertex colors, normals, tangents — is inherited +byte-identical from the source FBX clone (modulo what the +Unity FBX Exporter itself rewrites at the FBX-document level; +see §9). + +### `FbxExportIntent` flags + +| Flag | When to set | +|---|---| +| `UV0` / `UV1` / `UV2` / `UV3` … `UV7` | Tool overwrote the corresponding `Mesh.uv*` channel | +| `VertexColors` | Tool wrote `Mesh.colors` / `colors32` | +| `Normals` | Tool wrote `Mesh.normals` | +| `Tangents` | Tool wrote `Mesh.tangents` | +| `Hierarchy` | Tool added/removed/renamed nodes (LOD gen, collision injection, root pivot reset) | +| `Materials` | Tool reassigned `Renderer.sharedMaterials` | +| `Collision` | Tool changed `_COL` children (V-HACD, sidecar inject) | +| `LodGroup` | Tool added/removed LOD entries on the source LODGroup | +| `All` | LOD-rebuild scenario — every aspect changed | +| `None` | No-op (logged + early-return) | + +### Per-tool intent recipe + +| Tool | Intent it produces | +|---|---| +| `UvPackHierarchyTool` (atlas pack) | `UV2` | +| `LightmapTransferTool` UV2 transfer | `UV2` | +| `VertexColorBakingTool` (AO bake to vcolor) | `VertexColors` | +| `VertexColorBakingTool` (AO bake to UV channel N) | `VertexColors \| UV` | +| `Uv0Analyzer` / UV0 Optimize | `UV0` | +| `LodGenerationTool` (new LODs) | `Hierarchy \| LodGroup \| AnyUv \| VertexColors \| Normals \| Tangents \| Materials` | +| `CollisionMeshTool` (V-HACD into sidecar) | `Collision` | +| `LightmapTransferTool` "Rebuild LOD chain" | `All` | + +### Hard rules for every FBX-write path + +1. **No new public `Export*` methods.** Adding a parallel + write path duplicates the importer-prep / .meta-backup / + postprocessor coordination. Add an intent + delegate to + the existing core. +2. **Write to `*.fbx.tmp` first**, then `File.Replace` for + atomicity. The core handles this — never call + `ModelExporter.ExportObjects` directly on the source path. +3. **Pre-export preflight runs inside the core**, not at + call sites. Every caller benefits automatically. +4. **`.meta` survives the round-trip** via temp backup and + conditional restore — the core handles this too. +5. **Postprocessor coordination** (`Uv2AssetPostprocessor.bypassPaths` + on importer-prep reimport, `fbxOverwritePaths` on the + write itself) is the core's responsibility. Call sites + never touch these sets directly. + +### What "rework, not parallel" means in code review + +Reject PRs that: + +* Add a new method named `Export*Fbx*` on any tool other + than `LightmapTransferTool`. Tools push their changes + into mesh entries / sidecars and call the existing core + with the appropriate intent. +* Call `UnityEditor.Formats.Fbx.Exporter.ModelExporter.ExportObjects` + outside the core. +* Bypass intent (e.g. always passing `FbxExportIntent.All` + when the operation only changed one channel) — this + defeats the safe-resave contract and risks collateral + mutation of unrelated mesh data. +* Add `if (myToolDidThing) NormalizeExportHierarchy(...)` + branches at call sites — that pass belongs in the core, + gated by `intent.HasFlag(Hierarchy)`. diff --git a/Documentation~/VERTEX_COLOR_BAKING_ARCHITECTURE.md b/Documentation~/VERTEX_COLOR_BAKING_ARCHITECTURE.md new file mode 100644 index 00000000..46498fb6 --- /dev/null +++ b/Documentation~/VERTEX_COLOR_BAKING_ARCHITECTURE.md @@ -0,0 +1,289 @@ +# Vertex Color Baking — Architecture & Roadmap + +**Status:** design phase, scaffold not yet started +**Branch of record:** `claude/vertex-color-baking-sfTU3` +**Last update:** 2026-04-26 +**Owner:** Vertex Color Baking tab (`Editor/Tools/VertexColorBakingTool.cs`) + +This document captures the long-term architecture for the Vertex Color +Baking tab. The first deliverable (Phases 1–5 below) renamed the AO tab +and added a single Solid Color batch export flow. The rest of this doc +describes the layered system the tab will grow into so future stages +(noise, gradients, tint, blur, submesh, AO blends, etc.) compose +predictably instead of accumulating as ad-hoc `if/else` branches. + +--- + +## 1. Goals + +1. **Multiple bake modes that compose.** Solid, Gradient (axis-aligned), + Noise (random / Perlin / Voronoi, monochrome or colored), Tint, + Blur, Submesh tints, AO. Output is `mesh.colors32` (RGBA per vertex). +2. **Layer stack semantics** like Photoshop / Substance: ordered list + of layers, each with a stage type, blend op, opacity, and enable + toggle. Layers composite bottom-to-top into a single final color. +3. **Variant batch export** (already wired): one source FBX/prefab, + N output variants, each variant = its own layer stack + suffix. +4. **AO migrates into the same system** — `AOStage` wraps the existing + `VertexAOBaker` so AO can blend with Solid / Tint / Noise like any + other stage. Migration is the last roadmap step so the rich AO UI + keeps working until the layered system is stable. +5. **Persistence** — stacks survive Unity restarts and travel with the + prefab/FBX through git. Sidecar asset preferred over `EditorPrefs`. + +## 2. Non-goals (current scope) + +- Interactive vertex painting brush. +- Procedural curve UI for gradients (use `UnityEngine.Gradient` until a + user actually asks for more). +- Importing vertex colors from external textures or other meshes. +- Real-time preview shader inside the MeshLab viewport (Console-driven + feedback is enough until the layered system stabilizes). + +## 3. Current state — what already shipped + +The tab was renamed and a working Solid Color path exists end-to-end. +Future phases refactor this into the layered model below; nothing here +gets thrown away, but the entry points move. + +| Phase | Commit | Outcome | +|-------|---------|---------| +| 1 | `0a2cafc` | `VertexAOTool` → `VertexColorBakingTool`. Asset GUID preserved. | +| 2 | `ed3df9b` | `BakeKind` toolbar (`AO` / `Solid Color`) + Solid bake path that writes `mesh.colors32` with collision skip and `Undo.RecordObject`. | +| 3a | `e526307` | `LightmapTransferTool.ExportVertexColorsToFbxCore` accepts `outputFbxPathOverride`, returns `bool`. New public `ExportVertexColorsToFbxAs`. | +| 3b | `8d65ef2` | `VariantExportPipeline.cs` — bake → FBX export → prefab clone (full clone, not Prefab Variant). Suffix validation, `ConflictPolicy`, batch wrapped in `StartAssetEditing/StopAssetEditing`. | +| 4 | `badc0ec` | UI: `(Color, suffix)` variant list with `[+]/[−]`, preview line, `[Bake (preview)]` and `[Bake & Export All]` buttons. | +| 5 | `e88ff18` | README section + `[Unreleased]` CHANGELOG entry. | + +Known limitations these phases inherit from the "single solid color +per variant" design: + +- One `Color` per variant, not a stack. +- AO and Solid live in two disjoint UI branches with no shared compute + primitive. +- `VariantExportPipeline.BakeSolidColorOnEntries` is hardcoded to + uniform `Color32` — there is no place for a second stage. +- Bake writes `mesh.colors32` directly, so blends across stages are + not expressible. + +## 4. Target architecture + +### 4.1 Core types + +```csharp +internal interface IVertexColorBakeStage +{ + string DisplayName { get; } // shown in the layer header + string TypeId { get; } // stable id for serialization + void DrawSettings(); // own IMGUI block inside the layer foldout + void Compute(BakeContext ctx); // writes per-vertex Color32[] into ctx.Output +} + +[System.Serializable] +internal sealed class BakeLayer +{ + public string Name; // user-editable + public bool Enabled = true; + public BlendOp BlendOp = BlendOp.Replace; + [Range(0f, 1f)] public float Opacity = 1f; + [SerializeReference] public IVertexColorBakeStage Stage; + // future: per-layer mask (alpha multiplier sourced from another layer / channel) + // future: channel mask (R-only / RGB-only writes) +} + +[System.Serializable] +internal sealed class BakeLayerStack +{ + public Color BaseColor = Color.white; // canvas under all layers + public List Layers = new(); // [0] = bottom, [n-1] = top +} + +internal sealed class BakeContext +{ + public IList Entries; + public Dictionary Output; // current stage writes here + public Dictionary Accumulated; // composited so far (read-only for stage) + public BakeContextCache Cache; // world positions, triangles, etc. +} + +internal static class BakePipelineRunner +{ + public static void Run(BakeLayerStack stack, IList entries) + { + // 1. Initialize Accumulated[mesh] = filled with stack.BaseColor + // 2. For each enabled layer, bottom → top: + // Compute(ctx) into ctx.Output + // Accumulated = Blend(Accumulated, Output, layer.BlendOp, layer.Opacity) + // 3. One Undo.RecordObject per mesh + mesh.colors32 = Accumulated[mesh] + // EditorUtility.SetDirty(mesh) + } +} +``` + +### 4.2 Blend ops + +```csharp +internal enum BlendOp +{ + Replace, // dst = src + Multiply, // dst = a * b + Add, // dst = a + b (clamped) + Subtract, // dst = a - b (clamped) + Lerp, // dst = lerp(a, b, opacity) ← opacity already applied + Screen, // dst = 1 - (1 - a)*(1 - b) + Overlay, // photo-style overlay + Min, + Max, +} +``` + +`Replace`, `Multiply`, `Lerp` are the must-have set for the first +useful release. The rest land when a stage actually needs them. + +### 4.3 Folder layout + +``` +Editor/Tools/VertexColorBaking/ + VertexColorBakingTool.cs (UI host — owns the tab, hosts the active stack) + BakeLayer.cs + BakeLayerStack.cs + BakeContext.cs + BakePipelineRunner.cs + BlendOps.cs (static blend math) + StageRegistry.cs (typeId → factory; powers "Add layer ▾" menu) + Stages/ + IVertexColorBakeStage.cs + SolidStage.cs + GradientAxisStage.cs + NoiseStage.cs + TintStage.cs + BlurStage.cs + SubmeshStage.cs + AOStage.cs (last to land — wraps VertexAOBaker) + VariantExportPipeline.cs (already exists; gets refactored to take a stack instead of a Color) +``` + +`VertexColorBakingTool.cs` keeps the AO branch intact during the +migration so AO users are never broken. + +### 4.4 Stage catalog (planned) + +| Stage | Inputs | Output | +|--------------------|-------------------------------------------|---------------------------------| +| `SolidStage` | `Color` | uniform RGBA per vertex | +| `GradientAxisStage`| `Axis`, `UnityEngine.Gradient`, range | RGBA along world/local axis | +| `SubmeshStage` | `List<(submeshIdx, Color)>` | RGBA per submesh-owned vertex | +| `NoiseStage` | `Mode` (Random/Perlin/Voronoi), `Seed`, `Scale`, monochrome flag, range | per-vertex RGBA | +| `TintStage` | `Color`, mode (Multiply / HueShift) | full-stack tint adjustment | +| `BlurStage` | `Mode` (Topology / 3D), iterations, strength | smooths `ctx.Accumulated` and writes back; reuses `VertexAOBaker.BlurAO` / `BlurAO3D` | +| `AOStage` (later) | full AO settings struct | wraps `VertexAOBaker` GPU/CPU path | + +`BlurStage` is special: it reads `ctx.Accumulated` (everything below +it in the stack) and writes a smoothed version back. That makes the +stack `[Solid, Noise, Blur, Tint]` mean exactly what a user expects +("Tint is applied on top of a blurred Solid+Noise base"). + +### 4.5 Variants + +```csharp +[System.Serializable] +internal sealed class VariantSpec +{ + public string Suffix; + public BakeLayerStack Stack; +} +``` + +`VariantExportPipeline.ExportVariants` takes `IList`. Per +variant: `BakePipelineRunner.Run(spec.Stack, entries)` → existing +`ExportVertexColorsToFbxAs` → existing `BuildPrefabClone`. Output paths +unchanged: `{base}_{suffix}.fbx`, `{base}_{suffix}.prefab`. + +UI model: one "active" stack is editable on screen. `[Save as variant]` +deep-copies it into the variant list. Selecting a variant loads its +stack into the active editor. This avoids the complexity of +template + per-variant overrides. + +### 4.6 Persistence + +`[SerializeReference]` on `BakeLayer.Stage` lets Unity 6 serialize +polymorphic stage subclasses. The active stack lives on the tool until +the user opts in to persistence by saving to a sidecar asset: + +``` +Assets/Foo/TrainCarriage.fbx +Assets/Foo/TrainCarriage_uv2data.asset ← existing UV2 sidecar +Assets/Foo/TrainCarriage_bakestack.asset ← NEW: BakeLayerStack + variants +``` + +Decision deferred until the layered system has 2–3 working stages — +the schema needs real-world stages before we lock it in. + +## 5. Roadmap + +Each phase is one (or a few small) commits, AO branch stays usable +throughout. A user can stop the rollout at any phase boundary and ship +what they have. + +| Phase | Title | Scope | +|-------|-------------------------------|-------| +| **A** | Scaffold + `SolidStage` | Folder layout, `IVertexColorBakeStage`, `BakeLayer`, `BakeLayerStack`, `BakePipelineRunner`, `BlendOp`, `StageRegistry`. Re-implement Solid as a one-layer stack. UI behavior unchanged. | +| **B** | Layer Stack UI | Multi-layer editor: list with `[+] [−] [↑] [↓]`, foldout per layer with blend op + opacity + stage settings. Active stack only (no variants yet). | +| **C** | Geometry-driven stages | `GradientAxisStage`, `SubmeshStage`, `TintStage`. | +| **D** | Procedural & post stages | `NoiseStage` (Random / Perlin / Voronoi, mono+colored, ranged), `BlurStage` (Topology + 3D, reuses `VertexAOBaker.BlurAO` / `BlurAO3D`). | +| **E** | Variants on stacks | `VariantSpec` replaces the current `(Color, suffix)` struct. UI variant list with per-variant stack edit. `VariantExportPipeline` takes the new spec. | +| **F** | AO migration | `AOStage` wraps `VertexAOBaker`. Old AO branch in `OnDrawSidebar` removed. Tab becomes single-mode (always layer stack). | +| **G** | Sidecar persistence | `BakeStackAsset` saved next to FBX / prefab. Optional auto-save on change, manual save button. | + +## 6. Open decisions (to confirm before Phase A) + +These were aligned in the design conversation but should be re-checked +when Phase A starts: + +1. **`BlurStage` reads accumulated result, not its own input** — confirmed. + Stack semantics: `[Solid, Noise, Blur, Tint]` = "tint on top of a + blurred Solid+Noise base". +2. **AO migration is last (Phase F).** Keep the rich AO UI working + until the layered system has been used for real. +3. **Variant = its own complete stack** (no shared template, no + overrides). Copying between variants is via `[Save as variant]` / + `[Duplicate]`. +4. **Persistence via sidecar asset** (not `EditorPrefs`). Schema is + designed in Phase G when 2–3 stages exist; until then stacks are + in-memory only and lost on tool close. +5. **`SerializeReference` for stages.** Requires Unity 2019.3+; the + package targets `unity: "6000.0"` so this is safe. + +## 7. Constraints to honor + +These come from `CLAUDE.md` and apply to every phase: + +- Namespace `SashaRX.UnityMeshLab` for all new files. +- `internal` visibility for cross-tool helpers. +- All scene / asset mutations through `Undo.RecordObject` (or + `Undo.AddComponent` / `Undo.DestroyObjectImmediate` as appropriate). +- Logging via `UvtLog.Info` / `UvtLog.Warn` / `UvtLog.Error` with a + bracketed prefix; new code uses `[Vertex Colors]`. +- FBX-Exporter code stays gated by `#if LIGHTMAP_UV_TOOL_FBX_EXPORTER`. +- `RestoreWorkingMeshes()` before switching LODGroup context. +- Temporary meshes (per-variant clones, blur scratch buffers) destroyed + in `try/finally`. +- AssetDatabase batches wrapped in `StartAssetEditing` / + `StopAssetEditing` (or `AssetEditingScope` on Unity 6+). +- No new external dependencies. Reuse `VertexAOBaker.BlurAO` / + `BlurAO3D` for the blur stage. +- Collision meshes are skipped in every stage that paints geometry + (`MeshHygieneUtility.IsCollisionNodeName`). + +## 8. References + +- Tab entry point: `Editor/Tools/VertexColorBakingTool.cs` +- Variant pipeline (current): `Editor/Tools/VariantExportPipeline.cs` +- FBX export plumbing: `Editor/Tools/LightmapTransferTool.cs` + (`ExportVertexColorsToFbxCore`, `ExportVertexColorsToFbxAs`) +- Working-mesh / LOD context: `Editor/Framework/UvToolContext.cs`, + `Editor/Framework/MeshEntry.cs`, `Editor/Framework/UvToolHub.cs` +- AO baker (will be wrapped in Phase F): `Editor/VertexAOBaker.cs` +- Collision detection helper: `MeshHygieneUtility.IsCollisionNodeName` +- Existing design docs in `Documentation~/`: `EXPERIMENTS.md`, + `FBX_EXPORT_MODERNIZATION.md`. diff --git a/Editor/BuildValidator.cs b/Editor/BuildValidator.cs new file mode 100644 index 00000000..518fc09f --- /dev/null +++ b/Editor/BuildValidator.cs @@ -0,0 +1,114 @@ +// BuildValidator.cs — Pre-save validation for PrefabBuilder's Build Pipeline. +// Groups issues into NullRefs / Materials / Uv2Readable / Topology; callers +// decide which groups block the save. NullRefs and Materials are fatal — +// FBX export cannot succeed without them. Uv2Readable and Topology are +// surfaced as warnings. + +using System.Collections.Generic; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class BuildValidator + { + internal enum IssueGroup + { + NullRefs, + Materials, + Uv2Readable, + Topology + } + + internal class Issue + { + public IssueGroup group; + public string meshName; + public string detail; + public GameObject target; + } + + internal static bool IsBlocker(IssueGroup group) + => group == IssueGroup.NullRefs || group == IssueGroup.Materials; + + internal static List Run(UvToolContext ctx) + { + var issues = new List(); + if (ctx == null || ctx.MeshEntries == null) return issues; + + foreach (var e in ctx.MeshEntries) + { + if (!e.include) continue; + string label = e.renderer != null ? e.renderer.name : (e.fbxMesh != null ? e.fbxMesh.name : ""); + var target = e.renderer != null ? e.renderer.gameObject : null; + + CheckNullRefs(e, label, target, issues); + CheckMaterials(e, label, target, issues); + CheckUv2Readable(e, label, target, issues); + CheckTopology(e, label, target, issues); + } + + return issues; + } + + static void CheckNullRefs(MeshEntry e, string label, GameObject target, List issues) + { + if (e.renderer == null) + { + issues.Add(new Issue { group = IssueGroup.NullRefs, meshName = label, detail = "missing renderer", target = target }); + return; + } + if (e.meshFilter == null || e.meshFilter.sharedMesh == null) + issues.Add(new Issue { group = IssueGroup.NullRefs, meshName = label, detail = "null sharedMesh", target = target }); + if (e.fbxMesh == null) + issues.Add(new Issue { group = IssueGroup.NullRefs, meshName = label, detail = "no FBX source", target = target }); + } + + static void CheckMaterials(MeshEntry e, string label, GameObject target, List issues) + { + if (e.renderer == null) return; + var mats = e.renderer.sharedMaterials; + if (mats == null || mats.Length == 0) + { + issues.Add(new Issue { group = IssueGroup.Materials, meshName = label, detail = "no materials", target = target }); + return; + } + for (int i = 0; i < mats.Length; i++) + { + if (mats[i] == null) + issues.Add(new Issue { group = IssueGroup.Materials, meshName = label, detail = $"null material in slot {i}", target = target }); + else if (mats[i].shader == null) + issues.Add(new Issue { group = IssueGroup.Materials, meshName = label, detail = $"null shader in '{mats[i].name}'", target = target }); + } + } + + static void CheckUv2Readable(MeshEntry e, string label, GameObject target, List issues) + { + Mesh mesh = e.originalMesh ?? e.fbxMesh; + if (mesh == null) return; + if (!mesh.isReadable) + { + issues.Add(new Issue { group = IssueGroup.Uv2Readable, meshName = label, detail = "mesh not readable", target = target }); + return; + } + var uv2 = new List(); + mesh.GetUVs(1, uv2); + if (uv2.Count == 0) + issues.Add(new Issue { group = IssueGroup.Uv2Readable, meshName = label, detail = "missing UV2", target = target }); + } + + static void CheckTopology(MeshEntry e, string label, GameObject target, List issues) + { + Mesh mesh = e.originalMesh ?? e.fbxMesh; + if (mesh == null || !mesh.isReadable) return; + int deg = MeshHygieneUtility.CountDegenerateTriangles(mesh); + if (deg > 0) + issues.Add(new Issue { group = IssueGroup.Topology, meshName = label, detail = $"{deg} degenerate tris", target = target }); + int unused = MeshHygieneUtility.CountUnusedVertices(mesh); + if (unused > 0) + issues.Add(new Issue { group = IssueGroup.Topology, meshName = label, detail = $"{unused} unused verts", target = target }); + var seamVerts = Uv0Analyzer.GetFalseSeamVertices(mesh); + if (seamVerts != null && seamVerts.Count > 0) + issues.Add(new Issue { group = IssueGroup.Topology, meshName = label, detail = $"{seamVerts.Count} false-seam verts", target = target }); + } + } +} diff --git a/Editor/Tools/Uv0OptimizeTool.cs.meta b/Editor/BuildValidator.cs.meta similarity index 83% rename from Editor/Tools/Uv0OptimizeTool.cs.meta rename to Editor/BuildValidator.cs.meta index 3a424d90..3de78a37 100644 --- a/Editor/Tools/Uv0OptimizeTool.cs.meta +++ b/Editor/BuildValidator.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 98b3918716394c7f8a5e6045ea4b48bc +guid: 4e9a2c7b3f6d4e8591a2b7c4d8e1f3a6 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Editor/CheckerTexturePreview.cs b/Editor/CheckerTexturePreview.cs index e6c89354..e8deb3ca 100644 --- a/Editor/CheckerTexturePreview.cs +++ b/Editor/CheckerTexturePreview.cs @@ -64,8 +64,8 @@ static void RestoreAll() if (ShellColorModelPreview.IsActive) ShellColorModelPreview.Restore(); - // Model Builder preview is per-tool instance — static guard. - ModelBuilderPreview.RestoreIfActive(); + // Prefab Builder preview is per-tool instance — static guard. + PrefabBuilderPreview.RestoreIfActive(); // Lightmap preview is per-window instance — find open Mesh Lab windows and restore. var hubs = Resources.FindObjectsOfTypeAll(); diff --git a/Editor/Framework/UvToolHub.cs b/Editor/Framework/UvToolHub.cs index 55d0fa29..831e1dd1 100644 --- a/Editor/Framework/UvToolHub.cs +++ b/Editor/Framework/UvToolHub.cs @@ -52,7 +52,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); diff --git a/Editor/LodGroupUtility.cs b/Editor/LodGroupUtility.cs new file mode 100644 index 00000000..47c14d98 --- /dev/null +++ b/Editor/LodGroupUtility.cs @@ -0,0 +1,120 @@ +// LodGroupUtility.cs — Prefab-aware LODGroup rebuild + transition normalization. +// Shared by LodGenerationTool and PrefabBuilderTool so both end up with a clean +// component state after generation / edit (recreates the component instead of +// mutating in place, which sidesteps stale override tracking on prefab +// instances) and monotonically-decreasing transition heights. + +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace SashaRX.UnityMeshLab +{ + internal static class LodGroupUtility + { + /// + /// Destroy the current LODGroup (if any), re-add a fresh one, copy the + /// preserved settings, and assign . Returns + /// the new component. + /// + /// Doing destroy+add instead of in-place SetLODs avoids two + /// common prefab-instance headaches: + /// 1) Old LOD slot overrides layered on fresh geometry produce + /// phantom renderers after re-import. + /// 2) Re-serialization of a mutated LODGroup with stale renderer + /// refs sometimes hides prefab children on next domain reload. + /// Undo.DestroyObjectImmediate + Undo.AddComponent tracks the swap as + /// removed-then-added overrides, which the prefab system handles. + /// + internal static LODGroup Rebuild(GameObject root, LOD[] newLods) + { + if (root == null) return null; + + // Preserve user-facing LODGroup settings so the rebuild is + // transparent. + var old = root.GetComponent(); + var size = 1f; + var fadeMode = LODFadeMode.None; + var animateCrossFading = false; + var localReferencePoint = Vector3.zero; + bool wasEnabled = true; + if (old != null) + { + size = old.size; + fadeMode = old.fadeMode; + animateCrossFading = old.animateCrossFading; + localReferencePoint = old.localReferencePoint; + wasEnabled = old.enabled; + Undo.DestroyObjectImmediate(old); + } + + var lg = Undo.AddComponent(root); + lg.size = size; + lg.fadeMode = fadeMode; + lg.animateCrossFading = animateCrossFading; + lg.localReferencePoint = localReferencePoint; + lg.enabled = wasEnabled; + + if (newLods != null && newLods.Length > 0) + lg.SetLODs(NormalizeTransitions(newLods)); + + // Mark LODGroup settings as modified so prefab-instance overrides + // for size / fade / lods are persisted. + if (PrefabUtility.IsPartOfPrefabInstance(lg)) + PrefabUtility.RecordPrefabInstancePropertyModifications(lg); + + return lg; + } + + /// + /// Apply to in-place, + /// normalising transitions. Use when the LODGroup component doesn't + /// need to be recreated (e.g. transition-only edits). + /// + internal static void ApplyLods(LODGroup lg, LOD[] newLods) + { + if (lg == null || newLods == null) return; + Undo.RecordObject(lg, "Update LODs"); + lg.SetLODs(NormalizeTransitions(newLods)); + if (PrefabUtility.IsPartOfPrefabInstance(lg)) + PrefabUtility.RecordPrefabInstancePropertyModifications(lg); + } + + /// + /// Returns a LOD[] copy with strictly decreasing transition heights. + /// Unity requires LOD[i].screenRelativeTransitionHeight > + /// LOD[i+1].screenRelativeTransitionHeight; out-of-order entries cause + /// silent LOD flicker or the wrong mesh rendering. Preserves the + /// first value and nudges later ones down when needed. + /// + internal static LOD[] NormalizeTransitions(LOD[] lods) + { + if (lods == null || lods.Length == 0) return lods; + var copy = new LOD[lods.Length]; + System.Array.Copy(lods, copy, lods.Length); + + // Clamp the first into (0,1]. + if (copy[0].screenRelativeTransitionHeight <= 0f || copy[0].screenRelativeTransitionHeight > 1f) + copy[0] = new LOD(Mathf.Clamp(copy[0].screenRelativeTransitionHeight, 0.01f, 1f), copy[0].renderers); + + const float minStep = 0.001f; + for (int i = 1; i < copy.Length; i++) + { + float prev = copy[i - 1].screenRelativeTransitionHeight; + float h = copy[i].screenRelativeTransitionHeight; + if (h >= prev - minStep) + { + // Pull the current entry below the previous by at least + // minStep. Falling back to half of prev when the current + // value was absurd (>=prev) or missing. + h = h < prev - minStep ? h : Mathf.Max(prev * 0.5f, minStep); + copy[i] = new LOD(h, copy[i].renderers); + } + // Clamp lower bound. + if (copy[i].screenRelativeTransitionHeight < minStep) + copy[i] = new LOD(minStep, copy[i].renderers); + } + return copy; + } + } +} diff --git a/Editor/Tools/AtlasPackTool.cs.meta b/Editor/LodGroupUtility.cs.meta similarity index 83% rename from Editor/Tools/AtlasPackTool.cs.meta rename to Editor/LodGroupUtility.cs.meta index 28f15725..1968dcae 100644 --- a/Editor/Tools/AtlasPackTool.cs.meta +++ b/Editor/LodGroupUtility.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 045a5e3298954277b628345c1ec817db +guid: 8c4f2b3e5a7d4c6198e5f1a9d8b2c6e3 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Editor/LodPipelineOps.cs b/Editor/LodPipelineOps.cs new file mode 100644 index 00000000..09f2fe79 --- /dev/null +++ b/Editor/LodPipelineOps.cs @@ -0,0 +1,266 @@ +// LodPipelineOps.cs — Shared LOD generation helper used by LodGenerationTool +// and PrefabBuilderTool's Build Pipeline. Encapsulates the meshoptimizer-driven +// simplification loop, prefab unpacking, LODGroup rebuild, scaleInLightmap +// propagation, and MeshEntry registration so both tools run identical logic. + +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace SashaRX.UnityMeshLab +{ + internal static class LodPipelineOps + { + static readonly System.Text.RegularExpressions.Regex LodSuffixRegex = + new System.Text.RegularExpressions.Regex( + @"(_wc|_repack|_uvTransfer|_optimized|_LOD\d+)+$", + System.Text.RegularExpressions.RegexOptions.Compiled); + + static readonly System.Text.RegularExpressions.Regex TrailingLodRegex = + new System.Text.RegularExpressions.Regex( + @"[_\-\s]+LOD\d+$", + System.Text.RegularExpressions.RegexOptions.Compiled | + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + internal struct Options + { + public int count; + public float[] ratios; + public float targetError; + public float uv2Weight; + public float normalWeight; + public bool lockBorder; + public bool progressiveScaleInLightmap; + } + + internal struct LodInfo + { + public string meshName; + public int simplifiedTris; + public int lodLevel; + public float targetRatio; + public float actualRatio; + public bool hitErrorLimit; + } + + internal class Result + { + public bool ok; + public string error; + public List perLod = new List(); + public List generatedObjects = new List(); + } + + internal static Result Generate(UvToolContext ctx, int startLod, Options opts) + { + var result = new Result(); + if (ctx?.LodGroup == null) { result.error = "No LODGroup"; return result; } + if (opts.ratios == null || opts.count <= 0) { result.error = "No ratios"; return result; } + + var lgGo = ctx.LodGroup.gameObject; + if (PrefabUtility.IsPartOfPrefabInstance(lgGo)) + { + var outer = PrefabUtility.GetOutermostPrefabInstanceRoot(lgGo); + if (outer != null) + { + UvtLog.Info($"[LodPipelineOps] Unpacking prefab instance '{outer.name}' before LOD regeneration."); + PrefabUtility.UnpackPrefabInstance(outer, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + } + } + + var sourceMeshes = new List<(MeshEntry entry, Mesh mesh)>(); + foreach (var e in ctx.MeshEntries) + { + if (!e.include || e.lodIndex != ctx.SourceLodIndex) continue; + Mesh src = e.repackedMesh ?? e.originalMesh; + if (src != null) sourceMeshes.Add((e, src)); + } + if (sourceMeshes.Count == 0) { result.error = "No source meshes found"; return result; } + + UvToolContext.CompactLodArray(ctx.LodGroup, removeEmptySlots: true); + var lods = ctx.LodGroup.GetLODs(); + var newLods = new List(lods); + + UvProgress.Begin($"Generate LODs ({opts.count} levels)", cancelable: true); + try + { + for (int lodIdx = 0; lodIdx < opts.count; lodIdx++) + { + if (UvProgress.CancelRequested) break; + float ratio = opts.ratios[lodIdx]; + var settings = new MeshSimplifier.SimplifySettings + { + targetRatio = ratio, + targetError = opts.targetError, + uv2Weight = opts.uv2Weight, + normalWeight = opts.normalWeight, + lockBorder = opts.lockBorder, + uvChannel = 1 + }; + + float progress = (float)lodIdx / opts.count; + UvProgress.Report(progress, + $"LOD{startLod + lodIdx} (ratio {ratio:P0})"); + + var lodRenderers = new List(); + int lodLevel = startLod + lodIdx; + + var parentToContainer = new Dictionary(); + + foreach (var (entry, srcMesh) in sourceMeshes) + { + var r = MeshSimplifier.Simplify(srcMesh, settings); + if (!r.ok) { UvtLog.Error($"[LodPipelineOps] Failed on {srcMesh.name}: {r.error}"); continue; } + + int sourceTriCount = TriCount(srcMesh); + float actualRatio = sourceTriCount > 0 + ? (float)r.simplifiedTriCount / sourceTriCount : 1f; + bool hitLimit = actualRatio > ratio * 1.2f; + if (hitLimit) + UvtLog.Warn($"[LodPipelineOps] LOD{lodLevel}: target {ratio:P0} but got {actualRatio:P0} — increase Target Error"); + + string baseName = entry.fbxMesh != null ? entry.fbxMesh.name : srcMesh.name; + baseName = LodSuffixRegex.Replace(baseName, ""); + string meshName = baseName + "_LOD" + lodLevel; + r.simplifiedMesh.name = meshName; + + UvtLog.Info($"[LodPipelineOps] {meshName}: {r.originalTriCount} → {r.simplifiedTriCount} tris ({actualRatio:P0})"); + + result.perLod.Add(new LodInfo + { + meshName = meshName, + simplifiedTris = r.simplifiedTriCount, + lodLevel = lodLevel, + targetRatio = ratio, + actualRatio = actualRatio, + hitErrorLimit = hitLimit + }); + + if (entry.renderer != null) + { + var go = new GameObject(meshName); + + Transform srcParent = entry.renderer.transform.parent; + Transform lodGroupTransform = ctx.LodGroup.transform; + + if (srcParent != lodGroupTransform && srcParent != null) + { + if (parentToContainer.TryGetValue(srcParent, out var container)) + go.transform.SetParent(container, false); + else + go.transform.SetParent(lodGroupTransform, false); + } + else + { + go.transform.SetParent(lodGroupTransform, false); + } + + parentToContainer[entry.renderer.transform] = go.transform; + go.transform.localPosition = entry.renderer.transform.localPosition; + go.transform.localRotation = entry.renderer.transform.localRotation; + go.transform.localScale = entry.renderer.transform.localScale; + var mf = go.AddComponent(); + mf.sharedMesh = r.simplifiedMesh; + var mr = go.AddComponent(); + LightmapTransferTool.CopyRendererSettings(entry.renderer, mr); + + if (opts.progressiveScaleInLightmap && lodLevel > 0) + { + Undo.RecordObject(mr, "Set scaleInLightmap"); + mr.scaleInLightmap = Mathf.Pow(0.5f, lodLevel); + } + + GameObjectUtility.SetStaticEditorFlags(go, + GameObjectUtility.GetStaticEditorFlags(entry.renderer.gameObject)); + Undo.RegisterCreatedObjectUndo(go, "Generate LOD"); + result.generatedObjects.Add(go); + lodRenderers.Add(mr); + } + } + + if (lodRenderers.Count > 0) + { + if (lodLevel < newLods.Count) + { + var oldRenderers = newLods[lodLevel].renderers; + if (oldRenderers != null) + foreach (var oldR in oldRenderers) + if (oldR != null && oldR.gameObject != null) + Undo.DestroyObjectImmediate(oldR.gameObject); + newLods[lodLevel] = new LOD(newLods[lodLevel].screenRelativeTransitionHeight, lodRenderers.ToArray()); + } + else + { + float baseHeight = newLods.Count > 0 ? newLods[newLods.Count - 1].screenRelativeTransitionHeight : 0.5f; + newLods.Add(new LOD(baseHeight * 0.5f, lodRenderers.ToArray())); + } + } + } + + ctx.LodGroup = LodGroupUtility.Rebuild(ctx.LodGroup.gameObject, newLods.ToArray()); + AssetDatabase.SaveAssets(); + } + finally { UvProgress.End(); } + + foreach (var (entry, srcMesh) in sourceMeshes) + { + if (entry.renderer == null) continue; + if (!TrailingLodRegex.IsMatch(entry.renderer.name)) + { + Undo.RecordObject(entry.renderer.gameObject, "Rename LOD0"); + string newName = entry.renderer.gameObject.name + "_LOD0"; + UvtLog.Info($"[LodPipelineOps] Renamed source: {entry.renderer.gameObject.name} → {newName}"); + entry.renderer.gameObject.name = newName; + } + } + + RegisterNewLodEntries(ctx); + ctx.ClearAllCaches(); + result.ok = true; + return result; + } + + static void RegisterNewLodEntries(UvToolContext ctx) + { + var currentLods = ctx.LodGroup.GetLODs(); + for (int li = 0; li < currentLods.Length; li++) + { + bool alreadyRegistered = false; + foreach (var e in ctx.MeshEntries) + if (e.lodIndex == li) { alreadyRegistered = true; break; } + if (alreadyRegistered) continue; + + if (currentLods[li].renderers == null) continue; + foreach (var r in currentLods[li].renderers) + { + if (r == null) continue; + var mf = r.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + var fbm = mf.sharedMesh; + var uv2Check = new List(); + fbm.GetUVs(1, uv2Check); + ctx.MeshEntries.Add(new MeshEntry + { + lodIndex = li, + renderer = r, + meshFilter = mf, + originalMesh = fbm, + fbxMesh = fbm, + hasExistingUv2 = uv2Check.Count > 0, + meshGroupKey = UvToolContext.ExtractGroupKey(r.name) + }); + } + } + } + + static int TriCount(Mesh mesh) + { + if (mesh == null) return 0; + long indexCount = 0; + int subMeshCount = mesh.subMeshCount; + for (int i = 0; i < subMeshCount; i++) + indexCount += mesh.GetIndexCount(i); + return (int)(indexCount / 3L); + } + } +} diff --git a/Editor/LodPipelineOps.cs.meta b/Editor/LodPipelineOps.cs.meta new file mode 100644 index 00000000..472d37d5 --- /dev/null +++ b/Editor/LodPipelineOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a3f8e2d5b9c4d8e9a1b2c3d4e5f6a7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/MeshLabArtifactValidator.cs b/Editor/MeshLabArtifactValidator.cs new file mode 100644 index 00000000..a0ec7b9b --- /dev/null +++ b/Editor/MeshLabArtifactValidator.cs @@ -0,0 +1,319 @@ +// MeshLabArtifactValidator.cs — Detect symptoms of bad external MeshLab settings +// in imported meshes. Flags: collapsed UV seams (Merge Close Vertices), +// recalculated normals (UseExistingNormals OFF), broken UV1 parameterization +// (Flat Plane / wiped UV1). See Documentation~/EXPERIMENTS.md, section +// "External MeshLab — Settings to Avoid Artifacts". + +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace SashaRX.UnityMeshLab +{ + public static class MeshLabArtifactValidator + { + const float FlatNormalDot = 0.9999f; // cos(0.81°) — vertex normal matches face normal + const float FlatShadedTriThreshold = 0.90f; // ≥90% triangles flat-shaded → suspicious + const float DegenerateAreaEpsilon = 1e-12f; + const float DegenerateUv1Threshold = 0.05f; // ≥5% degenerate UV1 tris → suspicious + const float Uv1OutsideUnitThreshold = 0.05f; // ≥5% UV1 verts outside [0..1] → suspicious + const int MinVertsForSeamCheck = 16; // skip trivial meshes + + public class Report + { + public string meshName; + public int vertexCount; + public int triangleCount; + public int submeshCount; + + // Mesh has Read/Write disabled — CPU buffers are inaccessible, so + // no analysis could run. Reported instead of throwing. + public bool notReadable; + + // Symptom 1: Merge Close Vertices ate UV seams + public int uniquePositionCount; + public float splitVertexRatio; // (vertexCount - uniquePositionCount) / vertexCount + public bool seamsLikelyMerged; + + // Symptom 2: UseExistingNormals OFF → RecalculateNormals without smoothing + public bool hasNormals; + public int flatShadedTriCount; + public float flatShadedRatio; + public bool normalsLikelyRecalculated; + + // Symptom 3: Bad UV1 parameterization + public bool hasUv1; + public int degenerateUv1TriCount; + public float degenerateUv1Ratio; + public bool uv1AllZero; + public int uv1OutsideUnitCount; + public float uv1OutsideUnitRatio; + + public bool HasIssues => + seamsLikelyMerged + || normalsLikelyRecalculated + || (hasUv1 && (uv1AllZero + || degenerateUv1Ratio >= DegenerateUv1Threshold + || uv1OutsideUnitRatio >= Uv1OutsideUnitThreshold)); + + public string Summary => + $"'{meshName}' verts={vertexCount} tris={triangleCount} sub={submeshCount} | " + + $"uniquePos={uniquePositionCount} splitRatio={splitVertexRatio:P1} | " + + (hasNormals + ? $"flatTri={flatShadedTriCount}/{triangleCount} ({flatShadedRatio:P1}) | " + : "noNormals | ") + + (hasUv1 + ? $"uv1 degen={degenerateUv1TriCount} ({degenerateUv1Ratio:P1}) " + + $"oob={uv1OutsideUnitCount} ({uv1OutsideUnitRatio:P1}) zero={uv1AllZero}" + : "noUv1"); + } + + /// + /// Analyze a mesh for symptoms of bad external MeshLab export settings. + /// Pure read-only inspection — does not mutate the mesh. + /// + public static Report Validate(Mesh mesh) + { + if (mesh == null) return null; + + var report = new Report + { + meshName = mesh.name, + vertexCount = mesh.vertexCount, + submeshCount = mesh.subMeshCount, + }; + + // Read/Write disabled meshes throw on any CPU buffer access + // (mesh.vertices / .normals / .triangles / GetUVs). Common on + // imported FBX assets — report it instead of aborting the run. + if (!mesh.isReadable) + { + report.notReadable = true; + return report; + } + + var positions = mesh.vertices; + var normals = mesh.normals; + var triangles = mesh.triangles; + report.triangleCount = triangles.Length / 3; + + var uv1List = new List(); + mesh.GetUVs(1, uv1List); + report.hasUv1 = uv1List.Count == report.vertexCount && report.vertexCount > 0; + + CheckCollapsedSeams(report, positions); + CheckRecalculatedNormals(report, positions, normals, triangles); + if (report.hasUv1) CheckUv1Parameterization(report, uv1List, triangles); + + return report; + } + + /// + /// Validate and emit log lines via UvtLog. Info on clean, Warn on issues. + /// + public static Report ValidateAndLog(Mesh mesh, string contextLabel = null) + { + var report = Validate(mesh); + if (report == null) return null; + string ctx = string.IsNullOrEmpty(contextLabel) ? "" : $"[{contextLabel}] "; + if (report.notReadable) + { + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': mesh is not readable " + + "(enable Read/Write in the model importer to validate)."); + } + else if (report.HasIssues) + { + UvtLog.Warn($"[MeshLabValidator] {ctx}{report.Summary}"); + if (report.seamsLikelyMerged) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV seams likely merged " + + "(MeshLab 'Merge Close Vertices' or 'Remove Duplicate Vertices')."); + if (report.normalsLikelyRecalculated) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': normals likely recalculated " + + "(MeshLab FBX export 'UseExistingNormals' = OFF)."); + if (report.hasUv1 && report.uv1AllZero) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV1 is all zero — " + + "MeshLab parameterization wiped or never written."); + if (report.hasUv1 && report.degenerateUv1Ratio >= DegenerateUv1Threshold) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV1 has " + + $"{report.degenerateUv1Ratio:P1} degenerate triangles — likely 'Flat Plane' parameterization."); + if (report.hasUv1 && report.uv1OutsideUnitRatio >= Uv1OutsideUnitThreshold) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': " + + $"{report.uv1OutsideUnitRatio:P1} of UV1 verts outside [0..1] — parameterization not normalized."); + } + else + { + UvtLog.Info($"[MeshLabValidator] {ctx}{report.Summary} — clean."); + } + return report; + } + + // ── Symptom 1 ───────────────────────────────────────────────────────── + + static void CheckCollapsedSeams(Report report, Vector3[] positions) + { + if (positions == null || positions.Length == 0) return; + + var unique = new HashSet<(int, int, int)>(); + for (int i = 0; i < positions.Length; i++) + unique.Add(QuantizePos(positions[i])); + + report.uniquePositionCount = unique.Count; + report.splitVertexRatio = positions.Length > 0 + ? (positions.Length - unique.Count) / (float)positions.Length + : 0f; + + // Suspicious: no split vertices at all on a non-trivial mesh with + // multiple submeshes or UV1 — almost always means seams got merged. + bool nonTrivial = positions.Length >= MinVertsForSeamCheck; + bool hasSeamSignal = report.submeshCount > 1 || report.hasUv1; + report.seamsLikelyMerged = nonTrivial + && hasSeamSignal + && report.splitVertexRatio < 1e-4f; + } + + // ── Symptom 2 ───────────────────────────────────────────────────────── + + static void CheckRecalculatedNormals(Report report, Vector3[] positions, Vector3[] normals, int[] triangles) + { + report.hasNormals = normals != null && normals.Length == positions.Length && normals.Length > 0; + if (!report.hasNormals || triangles.Length < 3) return; + + int triCount = triangles.Length / 3; + int flatCount = 0; + for (int t = 0; t < triCount; t++) + { + int i0 = triangles[t * 3]; + int i1 = triangles[t * 3 + 1]; + int i2 = triangles[t * 3 + 2]; + if (i0 >= positions.Length || i1 >= positions.Length || i2 >= positions.Length) continue; + + Vector3 faceN = Vector3.Cross(positions[i1] - positions[i0], positions[i2] - positions[i0]); + if (faceN.sqrMagnitude < DegenerateAreaEpsilon) continue; + faceN.Normalize(); + + Vector3 n0 = normals[i0]; + Vector3 n1 = normals[i1]; + Vector3 n2 = normals[i2]; + if (n0.sqrMagnitude < 0.5f || n1.sqrMagnitude < 0.5f || n2.sqrMagnitude < 0.5f) continue; + + float d0 = Vector3.Dot(n0, faceN); + float d1 = Vector3.Dot(n1, faceN); + float d2 = Vector3.Dot(n2, faceN); + if (d0 >= FlatNormalDot && d1 >= FlatNormalDot && d2 >= FlatNormalDot) + flatCount++; + } + + report.flatShadedTriCount = flatCount; + report.flatShadedRatio = triCount > 0 ? flatCount / (float)triCount : 0f; + report.normalsLikelyRecalculated = triCount >= MinVertsForSeamCheck + && report.flatShadedRatio >= FlatShadedTriThreshold; + } + + // ── Symptom 3 ───────────────────────────────────────────────────────── + + static void CheckUv1Parameterization(Report report, List uv1, int[] triangles) + { + int triCount = triangles.Length / 3; + int degenerate = 0; + for (int t = 0; t < triCount; t++) + { + int i0 = triangles[t * 3]; + int i1 = triangles[t * 3 + 1]; + int i2 = triangles[t * 3 + 2]; + if (i0 >= uv1.Count || i1 >= uv1.Count || i2 >= uv1.Count) continue; + + Vector2 a = uv1[i1] - uv1[i0]; + Vector2 b = uv1[i2] - uv1[i0]; + float cross = a.x * b.y - a.y * b.x; + if (cross * cross < DegenerateAreaEpsilon) degenerate++; + } + report.degenerateUv1TriCount = degenerate; + report.degenerateUv1Ratio = triCount > 0 ? degenerate / (float)triCount : 0f; + + int oob = 0; + bool allZero = true; + for (int i = 0; i < uv1.Count; i++) + { + Vector2 uv = uv1[i]; + if (uv.sqrMagnitude > 1e-12f) allZero = false; + if (uv.x < 0f || uv.x > 1f || uv.y < 0f || uv.y > 1f) oob++; + } + report.uv1AllZero = allZero; + report.uv1OutsideUnitCount = oob; + report.uv1OutsideUnitRatio = uv1.Count > 0 ? oob / (float)uv1.Count : 0f; + } + + // ── Utility ─────────────────────────────────────────────────────────── + + static (int, int, int) QuantizePos(Vector3 p) => + (Mathf.RoundToInt(p.x * 100000f), + Mathf.RoundToInt(p.y * 100000f), + Mathf.RoundToInt(p.z * 100000f)); + + // ── Menu integration ────────────────────────────────────────────────── + + const string MenuPath = "Tools/Mesh Lab/Validators/Check Imported MeshLab Artifacts"; + + [MenuItem(MenuPath, true)] + static bool ValidateSelectionMenuValidate() => + CollectMeshes(Selection.objects).Count > 0; + + [MenuItem(MenuPath, false, 200)] + static void ValidateSelectionMenu() + { + var meshes = CollectMeshes(Selection.objects); + if (meshes.Count == 0) + { + UvtLog.Warn("[MeshLabValidator] Selection contains no meshes."); + return; + } + + int issueCount = 0; + foreach (var (mesh, label) in meshes) + { + var r = ValidateAndLog(mesh, label); + if (r != null && r.HasIssues) issueCount++; + } + UvtLog.Info($"[MeshLabValidator] Checked {meshes.Count} mesh(es), {issueCount} with issues."); + } + + static List<(Mesh mesh, string label)> CollectMeshes(Object[] objects) + { + var result = new List<(Mesh, string)>(); + var seen = new HashSet(); + if (objects == null) return result; + + foreach (var obj in objects) + { + if (obj == null) continue; + + if (obj is Mesh m && m != null && seen.Add(m)) + { + result.Add((m, AssetDatabase.GetAssetPath(m))); + continue; + } + + if (obj is GameObject go && go != null) + { + foreach (var mf in go.GetComponentsInChildren(true)) + if (mf != null && mf.sharedMesh != null && seen.Add(mf.sharedMesh)) + result.Add((mf.sharedMesh, mf.gameObject.name)); + foreach (var smr in go.GetComponentsInChildren(true)) + if (smr != null && smr.sharedMesh != null && seen.Add(smr.sharedMesh)) + result.Add((smr.sharedMesh, smr.gameObject.name)); + continue; + } + + string path = AssetDatabase.GetAssetPath(obj); + if (!string.IsNullOrEmpty(path)) + { + foreach (var sub in AssetDatabase.LoadAllAssetsAtPath(path)) + if (sub is Mesh subMesh && subMesh != null && seen.Add(subMesh)) + result.Add((subMesh, $"{System.IO.Path.GetFileName(path)}::{subMesh.name}")); + } + } + + return result; + } + } +} diff --git a/Editor/MeshLabArtifactValidator.cs.meta b/Editor/MeshLabArtifactValidator.cs.meta new file mode 100644 index 00000000..c40bacbe --- /dev/null +++ b/Editor/MeshLabArtifactValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae73dea31d7b46bc81c40f977856f7f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ModelBuilderPreview.cs b/Editor/PrefabBuilderPreview.cs similarity index 99% rename from Editor/ModelBuilderPreview.cs rename to Editor/PrefabBuilderPreview.cs index 12e9b3e4..b4e35a40 100644 --- a/Editor/ModelBuilderPreview.cs +++ b/Editor/PrefabBuilderPreview.cs @@ -1,4 +1,4 @@ -// ModelBuilderPreview.cs — 3D scene preview helper for Model Builder tool. +// PrefabBuilderPreview.cs — 3D scene preview helper for Prefab Builder tool. // Manages mesh clone backup/restore and visualization modes: // Channel visualization (vertex colors, normals, tangents, UV channels) // Edge wireframe overlay (colored by EdgeAnalyzer classification) @@ -11,7 +11,7 @@ namespace SashaRX.UnityMeshLab { - internal class ModelBuilderPreview + internal class PrefabBuilderPreview { // ── Backup ── @@ -30,7 +30,7 @@ struct RendererBackup // ── Safety guard: static instance for domain reload cleanup ── - internal static ModelBuilderPreview ActiveInstance { get; private set; } + internal static PrefabBuilderPreview ActiveInstance { get; private set; } internal static void RestoreIfActive() { diff --git a/Editor/ModelBuilderPreview.cs.meta b/Editor/PrefabBuilderPreview.cs.meta similarity index 100% rename from Editor/ModelBuilderPreview.cs.meta rename to Editor/PrefabBuilderPreview.cs.meta diff --git a/Editor/Tools/AtlasPackTool.cs b/Editor/Tools/AtlasPackTool.cs deleted file mode 100644 index 83dc10c8..00000000 --- a/Editor/Tools/AtlasPackTool.cs +++ /dev/null @@ -1,55 +0,0 @@ -// AtlasPackTool.cs — Stub: Multi-model atlas packing into one UV space. -// Placeholder for future implementation. - -using System; -using System.Collections.Generic; -using UnityEditor; - -namespace SashaRX.UnityMeshLab -{ - public class AtlasPackTool : IUvTool - { - UvToolContext ctx; - UvCanvasView canvas; - Action requestRepaint; - - public string ToolName => "Atlas Pack"; - public string ToolId => "atlas_pack"; - public int ToolOrder => 10; - - public Action RequestRepaint { set => requestRepaint = value; } - - public void OnActivate(UvToolContext ctx, UvCanvasView canvas) - { - this.ctx = ctx; - this.canvas = canvas; - } - - public void OnDeactivate() { } - public void OnRefresh() { } - - public void OnDrawSidebar() - { - EditorGUILayout.Space(8); - EditorGUILayout.LabelField("Atlas Pack", EditorStyles.boldLabel); - EditorGUILayout.Space(4); - EditorGUILayout.HelpBox( - "Multi-model atlas packing — combine UVs from multiple models into a shared atlas.\n\n" + - "Not yet implemented.", - MessageType.Info); - } - - public void OnDrawToolbarExtra() { } - public void OnDrawStatusBar() { } - - public void OnDrawCanvasOverlay(UvCanvasView canvas, float cx, float cy, float sz) { } - - public IEnumerable GetFillModes() - { - yield return new UvCanvasView.FillModeEntry { name = "Shells" }; - yield return new UvCanvasView.FillModeEntry { name = "Coverage" }; - } - - public void OnSceneGUI(SceneView sv) { } - } -} diff --git a/Editor/Tools/FbxExportIntent.cs b/Editor/Tools/FbxExportIntent.cs new file mode 100644 index 00000000..057d5d91 --- /dev/null +++ b/Editor/Tools/FbxExportIntent.cs @@ -0,0 +1,75 @@ +// FbxExportIntent.cs — describes which mesh channels an FBX re-save is +// allowed to mutate. Other tools snapshot the source FBX and write only +// the channels listed in the intent on the cloned mesh, so an isolated +// UV2 export will not touch UV0, UV1, vertex colors, normals, tangents, +// material assignments, mesh names, hierarchy, or transforms. + +using System; + +namespace SashaRX.UnityMeshLab +{ + /// + /// Channels that an isolated FBX re-save is allowed to overwrite. + /// Anything outside the intent is preserved from the source FBX + /// asset on disk via snapshot-and-restore in the export pipeline. + /// + [Flags] + public enum FbxExportIntent + { + None = 0, + + UV0 = 1 << 0, + UV1 = 1 << 1, + UV2 = 1 << 2, + UV3 = 1 << 3, + UV4 = 1 << 4, + UV5 = 1 << 5, + UV6 = 1 << 6, + UV7 = 1 << 7, + + VertexColors = 1 << 8, + Normals = 1 << 9, + Tangents = 1 << 10, + + // Non-per-vertex categories. These describe whole-asset mutations + // that the wide LOD-rebuild path performs; the narrow isolated + // re-save path requires all four to be clear so it can guarantee + // node names, materials, transforms and the LODGroup component + // come through byte-identical from the source FBX. + Hierarchy = 1 << 11, + Materials = 1 << 12, + Collision = 1 << 13, + LodGroup = 1 << 14, + + AnyUv = UV0 | UV1 | UV2 | UV3 | UV4 | UV5 | UV6 | UV7, + All = AnyUv | VertexColors | Normals | Tangents + | Hierarchy | Materials | Collision | LodGroup, + } + + internal static class FbxExportIntentExtensions + { + /// + /// True if the intent includes the per-vertex UV channel + /// (Unity Mesh.uv index 0-7). + /// + public static bool IncludesUv(this FbxExportIntent intent, int channel) + { + if ((uint)channel > 7u) return false; + return (intent & (FbxExportIntent)(1 << channel)) != 0; + } + + /// + /// True if the intent writes any per-vertex channel that depends on + /// stable vertex order (any UV, vertex colors, normals, tangents). + /// Used to decide whether the source FBX importer must lock weld / + /// compression / mesh-optimization before the snapshot is taken. + /// + public static bool TouchesPerVertex(this FbxExportIntent intent) + { + return (intent & (FbxExportIntent.AnyUv + | FbxExportIntent.VertexColors + | FbxExportIntent.Normals + | FbxExportIntent.Tangents)) != 0; + } + } +} diff --git a/Editor/Tools/FbxExportIntent.cs.meta b/Editor/Tools/FbxExportIntent.cs.meta new file mode 100644 index 00000000..28006a37 --- /dev/null +++ b/Editor/Tools/FbxExportIntent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e60767e02c448d3a5034c6a6b1f95b4 +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 4e680229..4e76e213 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -2477,7 +2477,7 @@ static bool TryGetAppliedAoUvTarget(out int uvChannel, out int uvComponent) uvChannel = -1; uvComponent = 0; - var ch = VertexAOTool.LastAppliedTargetChannel; + var ch = VertexColorBakingTool.LastAppliedTargetChannel; if (!ch.HasValue) return false; int v = (int)ch.Value; @@ -2547,7 +2547,7 @@ static bool HasUvChannelData(Mesh mesh, int channel) static Mesh SelectUv2Donor(MeshEntry entry, Mesh resultMesh, int uvChannel) { - // AO is written into selected UV component by VertexAOTool.ApplyToMesh, + // AO is written into selected UV component by VertexColorBakingTool.ApplyToMesh, // usually on original/fbx-backed working meshes. // Keep transferred mesh last // so UV1 transfer result stays authoritative while AO comes from AO donor. @@ -2657,7 +2657,8 @@ bool TryBuildSidecarEntry(MeshEntry entry, Mesh resultMesh, out MeshUv2Entry sid } } - public void ExportFbxPublic(bool overwriteSource) => ExportFbx(overwriteSource); + public void ExportFbxPublic(bool overwriteSource) => ExportFbx(overwriteSource, FbxExportIntent.All); + public void ExportFbxPublic(bool overwriteSource, FbxExportIntent intent) => ExportFbx(overwriteSource, intent); public void ApplyUv2Public() => ApplyUv2ToFbx(); public void SaveAllPublic() => SaveAll(); @@ -2690,45 +2691,534 @@ public void ExportVertexColorsToFbx() "Overwrite", "Cancel")) return; - // Determine AO target to scope importer setting changes. - // - aoUvIdx == -1: AO in vertex color → no importer changes needed - // - aoUvIdx >= 0: AO in UV channel → lock weld/compression/optimization - // - aoUvIdx == 1: AO in Unity UV channel 1 (lightmap UV) → also lock generateSecondaryUV - int aoUvIdx = -1; - var aoChannel = VertexAOTool.LastAppliedTargetChannel; - if (aoChannel.HasValue) + ExportVertexColorsToFbxCore(sourceFbxPath, ctx.MeshEntries); +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); +#endif + } + + // Hierarchy-mode entry point: export a specific FBX using a filtered + // entry list. Caller (VertexColorBakingTool) owns user confirmation. The FBX + // structure is preserved as-is (no LOD-style hierarchy normalization) + // so unrelated submeshes / instanced refs are not mutated. + public void ExportVertexColorsToFbx(string sourceFbxPath, IEnumerable entries, int uvChannelOverride = -1) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + if (string.IsNullOrEmpty(sourceFbxPath)) { - int ch = (int)aoChannel.Value; - if (ch > (int)AOTargetChannel.VertexColorA) - aoUvIdx = (ch - (int)AOTargetChannel.UV0_X) / 2; + UvtLog.Error("[FBX Export] Missing source FBX path."); + return; + } + var list = entries?.ToList(); + if (list == null || list.Count == 0) + { + UvtLog.Warn($"[FBX Export] No entries for '{sourceFbxPath}'."); + return; } - // ── Phase 1: Prepare importer (single reimport, scoped to AO target) ── - var srcImporter = AssetImporter.GetAtPath(sourceFbxPath) as ModelImporter; - bool needsReimport = false; - bool madeReadable = false; - if (srcImporter != null) - { - // generateSecondaryUV writes to Unity UV channel 1 (mesh.uv2). - // Only lock it when AO targets that specific channel. - if (aoUvIdx == 1 && srcImporter.generateSecondaryUV) - { srcImporter.generateSecondaryUV = false; needsReimport = true; } - // weld/compression/optimization change vertex count or order — - // break per-vertex UV data. Only lock when AO is in a UV channel. - if (aoUvIdx >= 0) + RestoreAllPreviews(); + ExportVertexColorsToFbxCore(sourceFbxPath, list, uvChannelOverride); +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); +#endif + } + + // Variant export: write painted meshes into a NEW FBX next to the + // source (or any caller-chosen path) without mutating the source FBX + // importer settings, scene mesh bindings, or working copies. Caller + // (VariantExportPipeline) owns suffix validation and conflict policy. + public bool ExportVertexColorsToFbxAs( + string sourceFbxPath, + string outputFbxPath, + IEnumerable entries, + int uvChannelOverride = -1) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + if (string.IsNullOrEmpty(sourceFbxPath) || string.IsNullOrEmpty(outputFbxPath)) + { + UvtLog.Error("[FBX Export] Variant export needs both source and output paths."); + return false; + } + var list = entries?.ToList(); + if (list == null || list.Count == 0) + { + UvtLog.Warn($"[FBX Export] No entries for variant export to '{outputFbxPath}'."); + return false; + } + + RestoreAllPreviews(); + // Vcolor shim never sets the Hierarchy bit: VariantExportPipeline + // matches new-FBX sub-meshes to source-prefab MeshFilters by + // sub-asset name, and hierarchy normalization (rename to + // baseName_LOD{N}) would break that matching. The variant FBX + // must mirror the source FBX's sub-mesh naming so prefab clones + // can swap mesh refs cleanly. + return ExportVertexColorsToFbxCore( + sourceFbxPath, list, + uvChannelOverride, + outputFbxPathOverride: outputFbxPath); +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); + return false; +#endif + } + + // Resolve the legacy vcolor flow's "AO target UV channel". + // Used by the vcolor wrappers to fold their args into a + // FbxExportIntent for the unified isolated-export core. + static int ResolveLegacyAoUvChannel(int uvChannelOverride) + { + if (uvChannelOverride >= 0) return uvChannelOverride; + var aoChannel = VertexColorBakingTool.LastAppliedTargetChannel; + if (!aoChannel.HasValue) return -1; + int ch = (int)aoChannel.Value; + if (ch <= (int)AOTargetChannel.VertexColorA) return -1; + return (ch - (int)AOTargetChannel.UV0_X) / 2; + } + + // Legacy shim. The implementation has been folded into + // ExportFbxIsolatedCore — this method only computes the + // FbxExportIntent for vcolor + optional AO-UV and delegates. + // Public wrappers (ExportVertexColorsToFbx*) keep their + // signatures so external callers (VariantExportPipeline, + // VertexColorBakingTool, UvPackHierarchyTool) are unaffected. + bool ExportVertexColorsToFbxCore( + string sourceFbxPath, + IEnumerable entries, + int uvChannelOverride = -1, + string outputFbxPathOverride = null) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + var intent = FbxExportIntent.VertexColors; + int aoUvIdx = ResolveLegacyAoUvChannel(uvChannelOverride); + if (aoUvIdx >= 0 && aoUvIdx <= 7) + intent |= (FbxExportIntent)(1 << aoUvIdx); + return ExportFbxIsolatedCore(sourceFbxPath, entries, intent, outputFbxPathOverride); +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); + return false; +#endif + } + + // ───────────────────────────────────────────────────────────────── + // Isolated-channel export (per FbxExportIntent) + // + // Re-saves the source FBX overwriting only the per-vertex channels + // listed in the intent. All other data — node names, hierarchy, + // transforms, material assignments, untouched UV channels, vertex + // colors, normals, tangents — is inherited from the source FBX + // asset on disk via clone-and-snapshot. Use this entry point when + // a tool changed exactly one aspect of the mesh (e.g. only UV2 + // from atlas pack) and must not collateral-mutate the rest. + // ───────────────────────────────────────────────────────────────── + + sealed class IsolatedExportSnapshot + { + public int vertexCount; + public Color32[] colors32; + public Color[] colors; + public Vector3[] normals; + public Vector4[] tangents; + public readonly Vector2[][] uvs = new Vector2[8][]; + } + + static IsolatedExportSnapshot BuildIsolatedSnapshot(Mesh source, FbxExportIntent intent) + { + var snap = new IsolatedExportSnapshot { vertexCount = source.vertexCount }; + if ((intent & FbxExportIntent.VertexColors) != 0) + { + var c32 = source.colors32; + if (c32 != null && c32.Length == source.vertexCount) + snap.colors32 = c32; + else + { + var c = source.colors; + if (c != null && c.Length == source.vertexCount) + snap.colors = c; + } + } + if ((intent & FbxExportIntent.Normals) != 0) + { + var n = source.normals; + if (n != null && n.Length == source.vertexCount) + snap.normals = n; + } + if ((intent & FbxExportIntent.Tangents) != 0) + { + var t = source.tangents; + if (t != null && t.Length == source.vertexCount) + snap.tangents = t; + } + for (int ch = 0; ch < 8; ch++) + { + if (!intent.IncludesUv(ch)) continue; + var list = new List(); + source.GetUVs(ch, list); + if (list.Count == source.vertexCount) + snap.uvs[ch] = list.ToArray(); + } + return snap; + } + + static int CopyIsolatedSnapshotsToClone( + GameObject tempRoot, + Dictionary snapshots) + { + if (snapshots == null) return 0; + int updated = 0; + int visited = 0; + int matched = 0; + foreach (var cloneMf in tempRoot.GetComponentsInChildren(true)) + { + if (cloneMf == null || cloneMf.sharedMesh == null) continue; + visited++; + if (!snapshots.TryGetValue(cloneMf.sharedMesh.name, out var snap)) continue; + matched++; + + if (snap.vertexCount != cloneMf.sharedMesh.vertexCount) { - if (srcImporter.weldVertices) { srcImporter.weldVertices = false; needsReimport = true; } - if (srcImporter.meshCompression != ModelImporterMeshCompression.Off) - { srcImporter.meshCompression = ModelImporterMeshCompression.Off; needsReimport = true; } - if (srcImporter.meshOptimizationFlags != 0) - { srcImporter.meshOptimizationFlags = 0; needsReimport = true; } + UvtLog.Warn($"[FBX Export] Skip '{cloneMf.sharedMesh.name}': vertex-count mismatch " + + $"(authored={snap.vertexCount}, FBX clone={cloneMf.sharedMesh.vertexCount}). " + + "The source FBX re-imports at a different vertex count than the tool worked on — " + + "usually 'Generate Lightmap UVs' splitting vertices. Disable it on the model " + + "importer and re-run the tool."); + continue; } - if (!srcImporter.isReadable) - { srcImporter.isReadable = true; needsReimport = true; madeReadable = true; } - if (needsReimport) + + // Clone before mutating — never write into the live FBX + // sub-asset shared by other scene MeshFilters. + var cloneMesh = UnityEngine.Object.Instantiate(cloneMf.sharedMesh); + cloneMesh.name = cloneMf.sharedMesh.name; + + if (snap.colors32 != null) { cloneMesh.colors32 = snap.colors32; updated++; } + else if (snap.colors != null) { cloneMesh.colors = snap.colors; updated++; } + if (snap.normals != null) { cloneMesh.normals = snap.normals; updated++; } + if (snap.tangents != null) { cloneMesh.tangents = snap.tangents; updated++; } + for (int ch = 0; ch < 8; ch++) { - Uv2AssetPostprocessor.bypassPaths.Add(sourceFbxPath); - srcImporter.SaveAndReimport(); + if (snap.uvs[ch] == null) continue; + if (snap.uvs[ch].Length != cloneMesh.vertexCount) continue; + cloneMesh.SetUVs(ch, snap.uvs[ch]); + updated++; + } + + cloneMf.sharedMesh = cloneMesh; + } + UvtLog.Verbose($"[FBX Export] CopyIsolatedSnapshotsToClone: visited={visited}, matched={matched}, updates={updated}."); + return updated; + } + + // ───────────────────────────────────────────────────────────────── + // Pre-export preflight — flags FBX-pipeline-checklist violations + // on the cloned hierarchy before export. Soft by design: every + // finding is logged via UvtLog.Warn, none block the export. + // The export is still atomic (write-to-tmp + File.Replace), so a + // logged violation that doesn't block here can be diagnosed and + // re-fixed without ever leaving a corrupt FBX on disk. + // ───────────────────────────────────────────────────────────────── + + static bool IsGenericMeshName(string name) + { + if (string.IsNullOrEmpty(name)) return true; + switch (name) + { + case "Scene": + case "Geometry": + case "Default": + case "Mesh": + case "Combined Mesh": + return true; + default: + return name.StartsWith("Combined Mesh", StringComparison.Ordinal); + } + } + + static bool IsPlaceholderMaterialName(string name) + { + if (string.IsNullOrEmpty(name)) return true; + switch (name) + { + case "Lit": + case "Default": + case "Material": + case "DefaultMaterial": + case "Default-Material": + case "No Name": + return true; + default: + return false; + } + } + + static void RunPreflight( + GameObject tempRoot, + FbxExportIntent intent, + Dictionary snapshots) + { + if (tempRoot == null) return; + + // §5.5 + §8: node + mesh names. Generic names (`Scene`, + // `Geometry`) are flagged because Max FBX importer auto-resets + // mesh attributes to `Scene` on round-trip — a name like that + // is a strong signal the source went through a metadata- + // stripping tool. Invalid characters break Addressables / + // asset bundles / filesystem rules. + int badNodeNames = 0; + int badMeshNames = 0; + foreach (var t in tempRoot.GetComponentsInChildren(true)) + { + if (string.IsNullOrEmpty(t.name) || MeshHygieneUtility.HasInvalidChars(t.name)) + badNodeNames++; + } + foreach (var mf in tempRoot.GetComponentsInChildren(true)) + { + var m = mf.sharedMesh; + if (m != null && IsGenericMeshName(m.name)) badMeshNames++; + } + foreach (var smr in tempRoot.GetComponentsInChildren(true)) + { + var m = smr.sharedMesh; + if (m != null && IsGenericMeshName(m.name)) badMeshNames++; + } + if (badNodeNames > 0) + UvtLog.Warn($"[FBX Preflight] {badNodeNames} node name(s) are empty or contain invalid characters (see §5.5/§8)."); + if (badMeshNames > 0) + UvtLog.Warn($"[FBX Preflight] {badMeshNames} mesh(es) have a generic name (Scene/Geometry/Default/empty); see §5.5."); + + // §1.5: placeholder material names. Soft because they may be + // intentional during an early authoring pass; warning surfaces + // them so they don't ship. + int placeholderMats = 0; + foreach (var mr in tempRoot.GetComponentsInChildren(true)) + { + var mats = mr.sharedMaterials; + if (mats == null) continue; + foreach (var mat in mats) + { + if (mat == null || IsPlaceholderMaterialName(mat.name)) + placeholderMats++; + } + } + if (placeholderMats > 0) + UvtLog.Warn($"[FBX Preflight] {placeholderMats} placeholder material slot(s) (Lit/Default/null); see §1.5."); + + // §4.2: vertex colors RGBA outside [0,1]. Only checked when the + // intent overwrites VertexColors — otherwise the channel comes + // straight from the source FBX and is the source's problem. + if ((intent & FbxExportIntent.VertexColors) != 0 && snapshots != null) + { + int outOfRangeMeshes = 0; + foreach (var snap in snapshots.Values) + { + bool hit = false; + var c = snap.colors; + if (c != null) + { + for (int i = 0; i < c.Length && !hit; i++) + { + var v = c[i]; + if (v.r < 0f || v.r > 1f || v.g < 0f || v.g > 1f || + v.b < 0f || v.b > 1f || v.a < 0f || v.a > 1f) hit = true; + } + } + // colors32 is byte-clamped by definition; nothing to check. + if (hit) outOfRangeMeshes++; + } + if (outOfRangeMeshes > 0) + UvtLog.Warn($"[FBX Preflight] {outOfRangeMeshes} mesh(es) have vertex colors outside [0,1]; see §4.2."); + } + + // §7.8: negative-determinant accumulated scale. Unity reads + // inverted normals as backface-culled — mesh appears + // transparent from the front. + int negScaleNodes = 0; + foreach (var t in tempRoot.GetComponentsInChildren(true)) + { + var s = t.lossyScale; + if (s.x * s.y * s.z < 0f) negScaleNodes++; + } + if (negScaleNodes > 0) + UvtLog.Warn($"[FBX Preflight] {negScaleNodes} node(s) have negative-determinant accumulated scale (mesh will render transparent from front); see §7.8."); + } + + /// + /// Re-save the FBX at overwriting + /// only the per-vertex channels listed in . + /// Mesh names, hierarchy, transforms, material assignments, and all + /// untouched per-vertex channels are preserved from the source FBX. + /// + /// Project path to the FBX to overwrite. + // Narrow-intent group dispatcher for ExportFbx. One core call + // per source FBX, reusing the standard "overwrite vs save-as" + // dialog flow but routing the actual write through the safe + // atomic core. Save-as without a project-relative path + // gracefully degrades to the absolute path the user picked + // (Unity's FBX exporter accepts both). + void ExportNarrowIntentGroups( + Dictionary> fbxGroups, + FbxExportIntent intent, + bool overwriteSource) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + int okCount = 0; + int totalCount = 0; + foreach (var kv in fbxGroups) + { + totalCount++; + string sourceFbxPath = kv.Key; + var entries = kv.Value.Select(p => p.entry).ToList(); + string outputFbxPath = null; + + if (overwriteSource) + { + if (!EditorUtility.DisplayDialog( + "Overwrite Source FBX", + $"Re-save '{System.IO.Path.GetFileName(sourceFbxPath)}' with intent {intent}?\n\n" + + "Channels not in the intent are preserved from the source FBX. " + + "Atomic write — original is untouched if export fails.", + "Overwrite", "Cancel")) + continue; + } + else + { + string dir = System.IO.Path.GetDirectoryName(sourceFbxPath); + string baseName = System.IO.Path.GetFileNameWithoutExtension(sourceFbxPath); + string suffix = (intent & FbxExportIntent.AnyUv) != 0 ? "_uv" : + (intent & FbxExportIntent.VertexColors) != 0 ? "_vcolor" : + "_isolated"; + string picked = EditorUtility.SaveFilePanel( + "Export FBX (isolated)", dir, baseName + suffix + ".fbx", "fbx"); + if (string.IsNullOrEmpty(picked)) continue; + string dataPath = Application.dataPath; + if (picked.StartsWith(dataPath, StringComparison.OrdinalIgnoreCase)) + outputFbxPath = "Assets" + picked.Substring(dataPath.Length); + else + outputFbxPath = picked; + } + + RestoreAllPreviews(); + if (ExportFbxIsolatedCore(sourceFbxPath, entries, intent, outputFbxPath)) + okCount++; + } + UvtLog.Info($"[FBX Export] Narrow-intent export: {okCount}/{totalCount} group(s) succeeded."); +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); +#endif + } + + /// Mesh entries supplying source data. Matched + /// against the FBX clone by sub-asset name. + /// Channels the caller is allowed to write. + /// is a no-op (logged + returns false). + public bool ExportIsolatedChannelsToFbx( + string sourceFbxPath, + IEnumerable entries, + FbxExportIntent intent) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + if (intent == FbxExportIntent.None) + { + UvtLog.Warn("[FBX Export] ExportIsolatedChannelsToFbx called with FbxExportIntent.None — nothing to write."); + return false; + } + if (string.IsNullOrEmpty(sourceFbxPath)) + { + UvtLog.Error("[FBX Export] ExportIsolatedChannelsToFbx: missing source FBX path."); + return false; + } + var list = entries?.ToList(); + if (list == null || list.Count == 0) + { + UvtLog.Warn($"[FBX Export] ExportIsolatedChannelsToFbx: no entries for '{sourceFbxPath}'."); + return false; + } + RestoreAllPreviews(); + return ExportFbxIsolatedCore(sourceFbxPath, list, intent, outputFbxPathOverride: null); +#else + UvtLog.Error("[FBX Export] FBX Exporter package not installed."); + return false; +#endif + } + + // Unified isolated-channel export core. EVERY in-tool FBX-write + // path goes through here — there is no parallel "destructive" + // pipeline. Hierarchy / Materials / Collision mutations are + // expressed as wider bits, gated + // inside this method. Adding a new caller-side ModelExporter. + // ExportObjects invocation is a checklist violation (§12). + bool ExportFbxIsolatedCore( + string sourceFbxPath, + IEnumerable entries, + FbxExportIntent intent, + string outputFbxPathOverride) + { +#if LIGHTMAP_UV_TOOL_FBX_EXPORTER + if (string.IsNullOrEmpty(sourceFbxPath) || entries == null) return false; + + string targetFbxPath = string.IsNullOrEmpty(outputFbxPathOverride) ? sourceFbxPath : outputFbxPathOverride; + bool isVariantExport = !string.IsNullOrEmpty(outputFbxPathOverride) + && !string.Equals(outputFbxPathOverride, sourceFbxPath, StringComparison.OrdinalIgnoreCase); + + // Snapshot pre-export. Captures only fields covered by intent. + // Phase 1 (importer prep) can trigger a reimport that resets the + // shared FBX sub-asset buffers in place — keying snapshots by + // sub-asset name lets us look up the original data after the + // reimport, when the in-memory mesh is back to its on-disk state. + var snapshots = new Dictionary(StringComparer.Ordinal); + foreach (var e in entries) + { + if (e == null || !e.include) continue; + Mesh sm = e.originalMesh ?? e.fbxMesh; + if (sm == null || string.IsNullOrEmpty(sm.name)) continue; + snapshots[sm.name] = BuildIsolatedSnapshot(sm, intent); + } + if (snapshots.Count == 0) + { + UvtLog.Warn($"[FBX Export] ExportIsolatedChannelsToFbx: no source meshes had data for intent {intent}."); + return false; + } + + // ── Phase 1: Prepare importer (single reimport, scoped to intent) ── + ModelImporter srcImporter = null; + bool madeReadable = false; + if (!isVariantExport) + { + srcImporter = AssetImporter.GetAtPath(sourceFbxPath) as ModelImporter; + bool needsReimport = false; + if (srcImporter != null) + { + // generateSecondaryUV writes Unity UV channel 1. + // Lock only when the intent overwrites that channel. + // Intentionally NOT restored in Phase 5: re-enabling it + // makes Unity regenerate channel 1 on the restore reimport + // and clobber the UV1 we just authored into the FBX. When + // the tool writes UV1, authored data must win, so the + // setting stays off. + if (intent.IncludesUv(1) && srcImporter.generateSecondaryUV) + { srcImporter.generateSecondaryUV = false; needsReimport = true; } + + // NOTE: we deliberately do NOT disable weldVertices / + // meshCompression / meshOptimizationFlags here. The snapshot + // is captured from the tool's working mesh, which was built + // from the CURRENT import; the clone below is also loaded + // from the current import, so the two share a vertex layout. + // Disabling weld/optimization and reimporting would renumber + // the clone's vertices, desyncing it from the snapshot — + // CopyIsolatedSnapshotsToClone would then skip every mesh on + // a vertex-count mismatch and write nothing. The wide + // LOD-rebuild path never does this reimport either. And + // because those settings were previously restored right + // after export, the final re-imported FBX kept the user's + // original weld/optimization state regardless — so removing + // the reimport changes nothing about the exported result + // except that the authored data now actually lands. + if (!srcImporter.isReadable) + { srcImporter.isReadable = true; needsReimport = true; madeReadable = true; } + if (needsReimport) + { + Uv2AssetPostprocessor.bypassPaths.Add(sourceFbxPath); + srcImporter.SaveAndReimport(); + } } } @@ -2737,47 +3227,128 @@ public void ExportVertexColorsToFbx() if (fbxAsset == null) { UvtLog.Error($"[FBX Export] Cannot load FBX asset at '{sourceFbxPath}'."); - return; + return false; } + // Clone the source FBX prefab as-is — preserves names, hierarchy, + // transforms, materials, and every channel the intent does not + // cover. CopyIsolatedSnapshotsToClone overwrites only the + // intended channels on freshly cloned per-node meshes. var tempRoot = UnityEngine.Object.Instantiate(fbxAsset); tempRoot.name = fbxAsset.name; int updated = 0; + bool exported = false; Dictionary renameMap = null; try { - updated = CopyVertexDataToClone(tempRoot); - if (updated == 0) + updated = CopyIsolatedSnapshotsToClone(tempRoot, snapshots); + if (updated == 0 && (intent & (FbxExportIntent.Hierarchy | FbxExportIntent.Materials)) == 0) { - UvtLog.Warn("[FBX Export] No vertex data found to export."); - return; + // Per-vertex-only intent with no matching meshes — nothing to write. + // Hierarchy / Materials intents are still meaningful with zero + // mesh updates (they restructure the FBX without per-vertex changes). + UvtLog.Warn($"[FBX Export] No matching meshes in clone for intent {intent}."); + return false; } - renameMap = NormalizeExportHierarchy(tempRoot); - PrepareCollisionMaterials(tempRoot); - TrimMaterialArrays(tempRoot); + // Hierarchy mutations — gated on Hierarchy bit. Renames children + // to baseName_LOD{N}, resets root to identity, bakes collision + // transforms into vertices. Returns oldName→newName map for + // post-reimport scene relink. + if ((intent & FbxExportIntent.Hierarchy) != 0) + renameMap = NormalizeExportHierarchy(tempRoot); + + // Material mutations — gated on Materials bit. PrepareCollisionMaterials + // copies a real material onto _COL renderers (avoids stale "Lit" + // default in the FBX). TrimMaterialArrays prunes sharedMaterials + // to subMeshCount. + if ((intent & FbxExportIntent.Materials) != 0) + { + PrepareCollisionMaterials(tempRoot); + TrimMaterialArrays(tempRoot); + } - // ── Phase 3: Export FBX ── - string fullPath = System.IO.Path.GetFullPath(sourceFbxPath); - // Hash the full path so two FBX files with the same filename - // (e.g. Assets/A/Chair.fbx and Assets/B/Chair.fbx) get distinct - // backup names and never overwrite each other. + // Pre-export preflight — surfaces FBX-pipeline-checklist + // violations on tempRoot before we commit to disk. Soft + // by design (logged, never blocks): collateral mutations + // we can't catch from snapshots show up here as warnings. + RunPreflight(tempRoot, intent, snapshots); + + // ── Phase 3: Export FBX (atomic) ── + // Write to .tmp first, verify, then File.Replace + // for atomic rename. If ModelExporter throws or writes a + // zero-byte file, the source FBX on disk is untouched — + // unlike direct overwrite, which leaves a corrupt FBX + // and a stale .meta when the exporter mid-faults. + string fullPath = System.IO.Path.GetFullPath(targetFbxPath); string pathHash = Math.Abs(fullPath.GetHashCode()).ToString("X8"); string metaBak = System.IO.Path.Combine( System.IO.Path.GetTempPath(), System.IO.Path.GetFileName(fullPath) + "." + pathHash + ".meta.bak"); - if (System.IO.File.Exists(fullPath + ".meta")) + bool metaBackedUp = System.IO.File.Exists(fullPath + ".meta"); + if (metaBackedUp) System.IO.File.Copy(fullPath + ".meta", metaBak, true); + string tmpRelPath = targetFbxPath + ".tmp"; + string tmpAbsPath = System.IO.Path.GetFullPath(tmpRelPath); + // Strip any leftover tmp from a prior crashed run. + if (System.IO.File.Exists(tmpAbsPath)) + System.IO.File.Delete(tmpAbsPath); + var exportOptions = new UnityEditor.Formats.Fbx.Exporter.ExportModelOptions { ExportFormat = UnityEditor.Formats.Fbx.Exporter.ExportFormat.Binary }; + + // Signal the UV2 postprocessor to skip sidecar UV2 injection + // on the reimport triggered by the rename below — otherwise + // an isolated UV2 export would be immediately overwritten by + // stale sidecar data. + Uv2AssetPostprocessor.fbxOverwritePaths.Add(targetFbxPath); + UnityEditor.Formats.Fbx.Exporter.ModelExporter.ExportObjects( - sourceFbxPath, new UnityEngine.Object[] { tempRoot }, exportOptions); + tmpRelPath, new UnityEngine.Object[] { tempRoot }, exportOptions); + + // Verify tmp file is sane before we commit. + var tmpInfo = new System.IO.FileInfo(tmpAbsPath); + if (!tmpInfo.Exists || tmpInfo.Length == 0) + { + if (System.IO.File.Exists(tmpAbsPath)) + System.IO.File.Delete(tmpAbsPath); + throw new System.IO.IOException( + $"FBX Exporter produced an empty/missing file at '{tmpRelPath}'."); + } + + // Atomic commit. File.Replace requires the target to exist + // (overwrite + backup). For a fresh write (variant export + // to a new path), File.Move is used. + if (System.IO.File.Exists(fullPath)) + { + string fbxBak = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + System.IO.Path.GetFileName(fullPath) + "." + pathHash + ".fbx.bak"); + System.IO.File.Replace(tmpAbsPath, fullPath, fbxBak); + // Backup served its purpose (rollback window during + // the rename itself). The .meta backup is still our + // primary safety net for the import settings. + if (System.IO.File.Exists(fbxBak)) + System.IO.File.Delete(fbxBak); + } + else + { + System.IO.File.Move(tmpAbsPath, fullPath); + } + + // ModelExporter may have generated a .meta for the .tmp + // sidecar entry — strip it so AssetDatabase doesn't pick + // up a ghost asset on the next refresh. + string tmpMetaPath = tmpAbsPath + ".meta"; + if (System.IO.File.Exists(tmpMetaPath)) + System.IO.File.Delete(tmpMetaPath); - UvtLog.Info($"[FBX Export] Vertex data ({updated} updates) -> {sourceFbxPath}"); + UvtLog.Info($"[FBX Export] Isolated channels {intent} ({updated} updates) -> {targetFbxPath}"); + exported = true; - if (System.IO.File.Exists(metaBak)) + if (metaBackedUp && System.IO.File.Exists(metaBak)) { System.IO.File.Copy(metaBak, fullPath + ".meta", true); System.IO.File.Delete(metaBak); @@ -2785,37 +3356,59 @@ public void ExportVertexColorsToFbx() } catch (Exception ex) { - UvtLog.Error("[FBX Export] Vertex color export failed: " + ex); - return; + Uv2AssetPostprocessor.fbxOverwritePaths.Remove(targetFbxPath); + // Best-effort: drop a leftover .tmp so a retry isn't blocked + // by the "Strip any leftover tmp" sweep above logging into + // a misleading state. + try + { + string tmpAbsPath = System.IO.Path.GetFullPath(targetFbxPath + ".tmp"); + if (System.IO.File.Exists(tmpAbsPath)) + System.IO.File.Delete(tmpAbsPath); + } + catch { /* swallow — original error matters */ } + UvtLog.Error("[FBX Export] Isolated channel export failed: " + ex); + return false; } finally { UnityEngine.Object.DestroyImmediate(tempRoot); } - // ── Phase 4: Reimport (single refresh) ── + // ── Phase 4: Reimport + relink ── + // Variant export skips scene relink — live scene must keep + // showing source meshes; only the new FBX needs to be picked up. AssetDatabase.Refresh(); - if (ctx.LodGroup != null) + if (!isVariantExport && ctx?.LodGroup != null) { + // renameMap is non-null only when the intent included + // Hierarchy and NormalizeExportHierarchy renamed nodes — + // for narrow per-vertex intents we re-bind purely by + // sub-asset name. RelinkSceneMeshReferences(sourceFbxPath, - renameMap != null && renameMap.Count > 0 ? renameMap : null, ctx.LodGroup); + renameMap != null && renameMap.Count > 0 ? renameMap : null, + ctx.LodGroup); ctx.Refresh(ctx.LodGroup); } - // ── Phase 5: Restore isReadable and working copies ── - // Restore isReadable to its original value so we don't silently - // change project import settings for users who intentionally keep - // Read/Write disabled. - if (madeReadable && srcImporter != null) + // ── Phase 5: Restore importer settings + working copies ── + // Only isReadable is restored: Phase 1 no longer touches weld / + // compression / optimization, and generateSecondaryUV is left + // disabled on purpose so the just-authored UV1 is not regenerated. + if (!isVariantExport) { - srcImporter.isReadable = false; - Uv2AssetPostprocessor.bypassPaths.Add(sourceFbxPath); - srcImporter.SaveAndReimport(); + if (srcImporter != null && madeReadable) + { + srcImporter.isReadable = false; + Uv2AssetPostprocessor.bypassPaths.Add(sourceFbxPath); + srcImporter.SaveAndReimport(); + } + RestoreWorkingCopiesToScene(); } - // Must be last — SaveAndReimport above resets MeshFilters again. - RestoreWorkingCopiesToScene(); + return exported; #else UvtLog.Error("[FBX Export] FBX Exporter package not installed."); + return false; #endif } @@ -2833,61 +3426,6 @@ string ResolveFbxPath() return null; } - int CopyVertexDataToClone(GameObject tempRoot) - { - int aoUvIdx = -1; - var aoChannel = VertexAOTool.LastAppliedTargetChannel; - if (aoChannel.HasValue) - { - int ch = (int)aoChannel.Value; - if (ch > (int)AOTargetChannel.VertexColorA) - aoUvIdx = (ch - (int)AOTargetChannel.UV0_X) / 2; - } - - int updated = 0; - foreach (var e in ctx.MeshEntries) - { - if (!e.include) continue; - Mesh sceneMesh = e.originalMesh ?? e.fbxMesh; - if (sceneMesh == null) continue; - - foreach (var cloneMf in tempRoot.GetComponentsInChildren(true)) - { - if (cloneMf == null || cloneMf.sharedMesh == null) continue; - if (cloneMf.sharedMesh.name != sceneMesh.name) continue; - - var cloneMesh = UnityEngine.Object.Instantiate(cloneMf.sharedMesh); - cloneMesh.name = cloneMf.sharedMesh.name; - - if (sceneMesh.colors32 != null && sceneMesh.colors32.Length == cloneMesh.vertexCount) - { - cloneMesh.colors32 = sceneMesh.colors32; - updated++; - } - else if (sceneMesh.colors != null && sceneMesh.colors.Length == cloneMesh.vertexCount) - { - cloneMesh.colors = sceneMesh.colors; - updated++; - } - - if (aoUvIdx >= 0 && sceneMesh.vertexCount == cloneMesh.vertexCount) - { - var uvs = new List(); - sceneMesh.GetUVs(aoUvIdx, uvs); - if (uvs.Count == cloneMesh.vertexCount) - { - cloneMesh.SetUVs(aoUvIdx, uvs); - updated++; - } - } - - cloneMf.sharedMesh = cloneMesh; - break; - } - } - return updated; - } - void PrepareCollisionMaterials(GameObject tempRoot) { Material colFallbackMat = null; @@ -2940,9 +3478,26 @@ void RestoreWorkingCopiesToScene() } } - void ExportFbx(bool overwriteSource) + void ExportFbx(bool overwriteSource) => ExportFbx(overwriteSource, FbxExportIntent.All); + + // ExportFbx with intent. Narrow intent (no Hierarchy and no + // LodGroup bits) delegates per-group to ExportFbxIsolatedCore — + // the safe atomic-write + preflight path. Wide intent (Hierarchy + // or LodGroup set) keeps the LOD-rebuild pipeline below: mesh + // replacement by name, stale-child pruning, NormalizeExport- + // Hierarchy, collision injection from sidecar. Migrating the + // wide path to atomic write is a follow-up — for now the LOD- + // rebuild scenario keeps direct overwrite for backwards + // compatibility with existing tooling that depends on its + // sequencing. + void ExportFbx(bool overwriteSource, FbxExportIntent intent) { #if LIGHTMAP_UV_TOOL_FBX_EXPORTER + if (intent == FbxExportIntent.None) + { + UvtLog.Warn("[FBX Export] ExportFbx called with FbxExportIntent.None — nothing to write."); + return; + } if (ctx?.MeshEntries == null || ctx.MeshEntries.Count == 0) { UvtLog.Error("[FBX Export] No meshes loaded."); @@ -3009,6 +3564,21 @@ void ExportFbx(bool overwriteSource) } if (fbxGroups.Count == 0) { UvtLog.Error("[FBX Export] No processed meshes to export."); return; } + // Narrow-intent fast path. When the caller is not asking for + // hierarchy / LOD-chain mutations, every group is exported + // through the safe core (atomic write, preflight, no + // NormalizeExportHierarchy, no material trim, no collision + // injection from sidecar). This is the path UV2 transfer / + // UV pack / vertex color baking should take — it preserves + // node names, transforms, materials and untouched per-vertex + // channels byte-for-byte (modulo what Unity's FBX Exporter + // itself rewrites at the FBX-document level). + if ((intent & (FbxExportIntent.Hierarchy | FbxExportIntent.LodGroup)) == 0) + { + ExportNarrowIntentGroups(fbxGroups, intent, overwriteSource); + return; + } + bool allGroupsSucceeded = true; var overwrittenFbxPaths = new HashSet(); var transientReplayEntriesByPath = new Dictionary>(); @@ -3101,7 +3671,14 @@ void ExportFbx(bool overwriteSource) var meshRendererTemplates = new Dictionary(); foreach (var (entry, resultMesh) in entries) { + string meshName = ResolveExportMeshName(entry, resultMesh); var exportMesh = UnityEngine.Object.Instantiate(resultMesh); + // Without an explicit name, Object.Instantiate produces "X(Clone)" + // and Unity's FBX Exporter falls back to the FBX scene name + // ("Scene") when writing the FbxMesh node — every reimported mesh + // ends up named "Scene". Pin the canonical name now so the FBX + // node and post-reimport mesh asset stay aligned with the source. + exportMesh.name = meshName; // Copy UV channels from fbxMesh first (base UVs), // then from originalMesh (has AO and other tool modifications). if (entry.fbxMesh != null) @@ -3123,8 +3700,10 @@ void ExportFbx(bool overwriteSource) if (uv2Donor != null) MergeUvComponentFromDonor(exportMesh, uv2Donor, aoUvChannel, aoUvComponent); } + // Keep exported tangents consistent with the source mesh + // (from main's TangentValidator). meshName is already + // resolved at the top of this loop body. TangentValidator.EnforceTangentsMatchOriginal(exportMesh, entry.fbxMesh, "FBX Export"); - string meshName = ResolveExportMeshName(entry, resultMesh); meshReplacements[meshName] = exportMesh; if (entry.renderer != null) meshRendererTemplates[meshName] = entry.renderer; @@ -3180,6 +3759,9 @@ void ExportFbx(bool overwriteSource) } var newMf = child.AddComponent(); var exportMesh = UnityEngine.Object.Instantiate(resultMesh); + // See the matching note in the replace-existing branch above: + // empty/Clone names cause Unity FBX Exporter to write "Scene". + exportMesh.name = meshName; if (entry.fbxMesh != null) PreserveUvChannels(exportMesh, entry.fbxMesh); if (entry.originalMesh != null && entry.originalMesh != entry.fbxMesh) @@ -3837,11 +4419,16 @@ static Dictionary NormalizeExportHierarchy(GameObject root) } } - // Normalize direct child LOD names to contiguous _LOD0.._LODN suffixes. - // This prevents importer-side warnings ("_LOD1 found but no _LOD0") - // when source names contained invalid characters (e.g. dots) and were - // sanitized inconsistently across tools. - var directLodChildren = new List<(Transform transform, int index)>(); + // Normalize direct child LOD names to contiguous _LOD0.._LODN suffixes + // PER GROUP. Group key = the child's own prefix before "_LOD" — so + // hierarchies with multiple LOD chains under one root (e.g. + // /_LOD0..2 + /_LOD0..2) keep their distinct + // prefixes instead of being collapsed into one _LOD0..N chain. + // Prevents importer warnings ("_LOD1 found but no _LOD0") when source + // names contained invalid characters (e.g. dots) and were sanitized + // inconsistently across tools. + var groupedLodChildren = new Dictionary>(); + var groupOrder = new List(); foreach (Transform child in root.transform) { if (MeshHygieneUtility.IsCollisionNodeName(child.name)) @@ -3856,33 +4443,41 @@ static Dictionary NormalizeExportHierarchy(GameObject root) var match = System.Text.RegularExpressions.Regex.Match( child.name, - @"_LOD(\d+)$", + @"^(.+)_LOD(\d+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (!match.Success) continue; int parsedIndex; - if (!int.TryParse(match.Groups[1].Value, out parsedIndex)) + if (!int.TryParse(match.Groups[2].Value, out parsedIndex)) continue; - directLodChildren.Add((child, parsedIndex)); + string groupPrefix = match.Groups[1].Value; + if (!groupedLodChildren.TryGetValue(groupPrefix, out var list)) + { + list = new List<(Transform, int, int)>(); + groupedLodChildren[groupPrefix] = list; + groupOrder.Add(groupPrefix); + } + list.Add((child, parsedIndex, child.GetSiblingIndex())); } - if (directLodChildren.Count > 0) + foreach (var groupPrefix in groupOrder) { - directLodChildren.Sort((a, b) => + var list = groupedLodChildren[groupPrefix]; + list.Sort((a, b) => { int cmp = a.index.CompareTo(b.index); - return cmp != 0 ? cmp : a.transform.GetSiblingIndex().CompareTo(b.transform.GetSiblingIndex()); + return cmp != 0 ? cmp : a.siblingIndex.CompareTo(b.siblingIndex); }); - for (int i = 0; i < directLodChildren.Count; i++) + for (int i = 0; i < list.Count; i++) { - string normalizedName = sanitizedBaseName + "_LOD" + i; - string oldName = directLodChildren[i].transform.name; + string normalizedName = groupPrefix + "_LOD" + i; + string oldName = list[i].transform.name; if (oldName != normalizedName) { - directLodChildren[i].transform.name = normalizedName; + list[i].transform.name = normalizedName; renameMap[oldName] = normalizedName; } } @@ -4413,7 +5008,7 @@ void RestoreAllPreviews() shellColorPreviewEnabled = false; } if (lightmapPreviewActive) RestoreLightmapPreview(); - VertexAOTool.ActiveInstance?.RestorePreview(); + VertexColorBakingTool.ActiveInstance?.RestorePreview(); canvas.CurrentPreviewMode = UvCanvasView.PreviewMode.Off; } diff --git a/Editor/Tools/LodGenerationTool.cs b/Editor/Tools/LodGenerationTool.cs index 1ae9252f..362dfd0a 100644 --- a/Editor/Tools/LodGenerationTool.cs +++ b/Editor/Tools/LodGenerationTool.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Text.RegularExpressions; using UnityEngine; -using UnityEngine.Rendering; using UnityEditor; namespace SashaRX.UnityMeshLab @@ -329,188 +328,33 @@ void ExecGenerateLods(int startLod) if (generatedObjects.Count > 0) ClearGeneratedLods(); lastResults.Clear(); - var sourceMeshes = new List<(MeshEntry entry, Mesh mesh)>(); - foreach (var e in ctx.MeshEntries) + var opts = new LodPipelineOps.Options { - if (!e.include || e.lodIndex != ctx.SourceLodIndex) continue; - Mesh src = e.repackedMesh ?? e.originalMesh; - if (src != null) sourceMeshes.Add((e, src)); - } - if (sourceMeshes.Count == 0) { UvtLog.Error("[GenerateLOD] No source meshes found."); return; } - - // No .asset files — meshes live in memory, exported via FBX - - UvToolContext.CompactLodArray(ctx.LodGroup, removeEmptySlots: true); - var lods = ctx.LodGroup.GetLODs(); - var newLods = new List(lods); - - UvProgress.Begin($"Generate LODs ({generateLodCount} levels)", cancelable: true); - try + count = generateLodCount, + ratios = generateLodRatios, + targetError = generateTargetError, + uv2Weight = generateUv2Weight, + normalWeight = generateNormalWeight, + lockBorder = generateLockBorder, + progressiveScaleInLightmap = false + }; + + var result = LodPipelineOps.Generate(ctx, startLod, opts); + if (!result.ok) { UvtLog.Error($"[GenerateLOD] {result.error}"); return; } + + generatedObjects.AddRange(result.generatedObjects); + foreach (var info in result.perLod) { - for (int lodIdx = 0; lodIdx < generateLodCount; lodIdx++) + lastResults.Add(new GeneratedLodInfo { - if (UvProgress.CancelRequested) break; - float ratio = generateLodRatios[lodIdx]; - var settings = new MeshSimplifier.SimplifySettings - { - targetRatio = ratio, - targetError = generateTargetError, - uv2Weight = generateUv2Weight, - normalWeight = generateNormalWeight, - lockBorder = generateLockBorder, - uvChannel = 1 - }; - - float progress = (float)lodIdx / generateLodCount; - UvProgress.Report(progress, - $"LOD{startLod + lodIdx} (ratio {ratio:P0})"); - - var lodRenderers = new List(); - int lodLevel = startLod + lodIdx; - - // Build source parent → LOD container mapping for hierarchy preservation - // If source renderers are nested (e.g. LOD0/Door_LOD0), create matching containers - var parentToContainer = new Dictionary(); - - foreach (var (entry, srcMesh) in sourceMeshes) - { - var r = MeshSimplifier.Simplify(srcMesh, settings); - if (!r.ok) { UvtLog.Error($"[GenerateLOD] Failed on {srcMesh.name}: {r.error}"); continue; } - - int sourceTriCount = GetTriangleCount(srcMesh); - float actualRatio = sourceTriCount > 0 - ? (float)r.simplifiedTriCount / sourceTriCount : 1f; - bool hitLimit = actualRatio > ratio * 1.2f; - if (hitLimit) - UvtLog.Warn($"[GenerateLOD] LOD{lodLevel}: target {ratio:P0} but got {actualRatio:P0} — increase Target Error"); - - string baseName = entry.fbxMesh != null ? entry.fbxMesh.name : srcMesh.name; - baseName = Regex.Replace(baseName, @"(_wc|_repack|_uvTransfer|_optimized|_LOD\d+)+$", ""); - string meshName = baseName + "_LOD" + lodLevel; - r.simplifiedMesh.name = meshName; - - // Mesh stays in memory — exported to FBX via sidebar footer button - UvtLog.Info($"[GenerateLOD] {meshName}: {r.originalTriCount} → {r.simplifiedTriCount} tris ({actualRatio:P0})"); - - lastResults.Add(new GeneratedLodInfo - { - meshName = meshName, - simplifiedTris = r.simplifiedTriCount, - lodLevel = lodLevel, - targetRatio = ratio, - actualRatio = actualRatio, - hitErrorLimit = hitLimit - }); - - // Create scene GameObject preserving source hierarchy - if (entry.renderer != null) - { - var go = new GameObject(meshName); - - // Determine correct parent: mirror source hierarchy - Transform srcParent = entry.renderer.transform.parent; - Transform lodGroupTransform = ctx.LodGroup.transform; - - if (srcParent != lodGroupTransform && srcParent != null) - { - // Nested renderer — find or create matching container - if (parentToContainer.TryGetValue(srcParent, out var container)) - go.transform.SetParent(container, false); - else - go.transform.SetParent(lodGroupTransform, false); - } - else - { - go.transform.SetParent(lodGroupTransform, false); - } - - // Register this GO as container for its source transform - // so nested renderers can be parented under it - parentToContainer[entry.renderer.transform] = go.transform; - go.transform.localPosition = entry.renderer.transform.localPosition; - go.transform.localRotation = entry.renderer.transform.localRotation; - go.transform.localScale = entry.renderer.transform.localScale; - var mf = go.AddComponent(); - mf.sharedMesh = r.simplifiedMesh; - var mr = go.AddComponent(); - LightmapTransferTool.CopyRendererSettings(entry.renderer, mr); - GameObjectUtility.SetStaticEditorFlags(go, - GameObjectUtility.GetStaticEditorFlags(entry.renderer.gameObject)); - Undo.RegisterCreatedObjectUndo(go, "Generate LOD"); - generatedObjects.Add(go); - lodRenderers.Add(mr); - } - } - - if (lodRenderers.Count > 0) - { - if (lodLevel < newLods.Count) - { - // Replace existing LOD - var oldRenderers = newLods[lodLevel].renderers; - if (oldRenderers != null) - foreach (var oldR in oldRenderers) - if (oldR != null && oldR.gameObject != null) - Undo.DestroyObjectImmediate(oldR.gameObject); - newLods[lodLevel] = new LOD(newLods[lodLevel].screenRelativeTransitionHeight, lodRenderers.ToArray()); - } - else - { - float baseHeight = newLods.Count > 0 ? newLods[newLods.Count - 1].screenRelativeTransitionHeight : 0.5f; - newLods.Add(new LOD(baseHeight * 0.5f, lodRenderers.ToArray())); - } - } - } - - Undo.RecordObject(ctx.LodGroup, "Generate LODs"); - ctx.LodGroup.SetLODs(newLods.ToArray()); - AssetDatabase.SaveAssets(); - } - finally { UvProgress.End(); } - - // Rename source LOD0 renderers to add _LOD0 suffix for cross-LOD matching - foreach (var (entry, srcMesh) in sourceMeshes) - { - if (entry.renderer == null) continue; - bool hasLodSuffix = Regex.IsMatch( - entry.renderer.name, @"[_\-\s]+LOD\d+$", - RegexOptions.IgnoreCase); - if (!hasLodSuffix) - { - Undo.RecordObject(entry.renderer.gameObject, "Rename LOD0"); - string newName = entry.renderer.gameObject.name + "_LOD0"; - UvtLog.Info($"[GenerateLOD] Renamed source: {entry.renderer.gameObject.name} → {newName}"); - entry.renderer.gameObject.name = newName; - } - } - - // Add new MeshEntries for generated LODs without touching existing state - var currentLods = ctx.LodGroup.GetLODs(); - for (int li = 0; li < currentLods.Length; li++) - { - if (ctx.MeshEntries.Any(e => e.lodIndex == li)) continue; - if (currentLods[li].renderers == null) continue; - foreach (var r in currentLods[li].renderers) - { - if (r == null) continue; - var mf = r.GetComponent(); - if (mf == null || mf.sharedMesh == null) continue; - var fbm = mf.sharedMesh; - var uv2Check = new List(); - fbm.GetUVs(1, uv2Check); - ctx.MeshEntries.Add(new MeshEntry - { - lodIndex = li, - renderer = r, - meshFilter = mf, - originalMesh = fbm, - fbxMesh = fbm, - hasExistingUv2 = uv2Check.Count > 0, - meshGroupKey = UvToolContext.ExtractGroupKey(r.name) - }); - } + meshName = info.meshName, + simplifiedTris = info.simplifiedTris, + lodLevel = info.lodLevel, + targetRatio = info.targetRatio, + actualRatio = info.actualRatio, + hitErrorLimit = info.hitErrorLimit + }); } - ctx.ClearAllCaches(); requestRepaint?.Invoke(); } diff --git a/Editor/Tools/ModelBuilderTool.cs b/Editor/Tools/PrefabBuilderTool.cs similarity index 78% rename from Editor/Tools/ModelBuilderTool.cs rename to Editor/Tools/PrefabBuilderTool.cs index 20ec5ebd..9b9daae5 100644 --- a/Editor/Tools/ModelBuilderTool.cs +++ b/Editor/Tools/PrefabBuilderTool.cs @@ -1,22 +1,24 @@ -// ModelBuilderTool.cs — Model Builder tool (IUvTool tab). +// 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. using System.Collections.Generic; +using System.Linq; using UnityEngine; using UnityEditor; +using UnityEditor.SceneManagement; namespace SashaRX.UnityMeshLab { - public class ModelBuilderTool : IUvTool + public class PrefabBuilderTool : IUvTool { UvToolContext ctx; UvCanvasView canvas; System.Action requestRepaint; // ── Identity ── - public string ToolName => "Model Builder"; - public string ToolId => "model_builder"; + public string ToolName => "Prefab Builder"; + public string ToolId => "prefab_builder"; public int ToolOrder => 44; public System.Action RequestRepaint { set => requestRepaint = value; } @@ -33,7 +35,7 @@ enum PreviewMode } PreviewMode previewMode = PreviewMode.None; - ModelBuilderPreview preview; + PrefabBuilderPreview preview; // ── Hierarchy editing state ── Dictionary pendingNames; // instanceID → edited name @@ -59,6 +61,26 @@ struct MergeGroup // ── 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 ── bool collisionFoldout; @@ -86,7 +108,7 @@ public void OnActivate(UvToolContext ctx, UvCanvasView canvas) { this.ctx = ctx; this.canvas = canvas; - if (preview == null) preview = new ModelBuilderPreview(); + if (preview == null) preview = new PrefabBuilderPreview(); pendingNames = new Dictionary(); } @@ -105,6 +127,8 @@ public void OnRefresh() pendingNames?.Clear(); splitCandidates = null; mergeCandidates = null; + buildIssues = null; + buildIntent = FbxExportIntent.None; } // ── UI: Sidebar ── @@ -112,7 +136,7 @@ public void OnRefresh() public void OnDrawSidebar() { EditorGUILayout.Space(8); - EditorGUILayout.LabelField("Model Builder", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Prefab Builder", EditorStyles.boldLabel); EditorGUILayout.Space(4); if (ctx == null || (ctx.LodGroup == null && !ctx.StandaloneMesh)) @@ -126,6 +150,7 @@ public void OnDrawSidebar() DrawPreviewModeToolbar(); DrawHierarchySection(); DrawLodManagementSection(); + DrawBuildPipelineSection(); DrawCollisionSection(); DrawSplitMergeSection(); DrawMeshInfo(); @@ -421,7 +446,7 @@ void ApplyPendingNames() { if (pendingNames == null || pendingNames.Count == 0) return; - Undo.SetCurrentGroupName("Model Builder: Rename"); + Undo.SetCurrentGroupName("Prefab Builder: Rename"); int group = Undo.GetCurrentGroup(); foreach (var kvp in pendingNames) @@ -437,6 +462,8 @@ void ApplyPendingNames() Undo.CollapseUndoOperations(group); pendingNames.Clear(); + buildIntent |= FbxExportIntent.Hierarchy; + // Refresh context since names changed if (ctx.LodGroup != null) ctx.Refresh(ctx.LodGroup); requestRepaint?.Invoke(); @@ -446,7 +473,7 @@ void NormalizeHierarchy() { if (ctx.LodGroup == null) return; - Undo.SetCurrentGroupName("Model Builder: Normalize"); + Undo.SetCurrentGroupName("Prefab Builder: Normalize"); int group = Undo.GetCurrentGroup(); var root = ctx.LodGroup.transform; @@ -584,6 +611,16 @@ void NormalizeHierarchy() Undo.CollapseUndoOperations(group); pendingNames?.Clear(); + // 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; + ctx.Refresh(ctx.LodGroup); requestRepaint?.Invoke(); UvtLog.Info("Normalized hierarchy."); @@ -820,6 +857,258 @@ void RebuildLodGroupFromNames() ctx.LodGroup.RecalculateBounds(); } + // ═══════════════════════════════════════════════════════════ + // Build Pipeline section: Open Prefab → Generate LODs (with + // progressive scaleInLightmap) → Validate → Save FBX. + // ═══════════════════════════════════════════════════════════ + + void DrawBuildPipelineSection() + { + EditorGUILayout.Space(8); + buildFoldout = EditorGUILayout.Foldout(buildFoldout, "Build Pipeline", true); + if (!buildFoldout) return; + + if (ctx.LodGroup == null) + { + EditorGUILayout.HelpBox("No LODGroup selected.", MessageType.Info); + return; + } + + DrawBuildOpenPrefab(); + EditorGUILayout.Space(6); + DrawBuildGenerateLods(); + EditorGUILayout.Space(6); + DrawBuildValidate(); + EditorGUILayout.Space(6); + DrawBuildSave(); + } + + void DrawBuildOpenPrefab() + { + EditorGUILayout.LabelField("Open Prefab", EditorStyles.miniBoldLabel); + + var stage = PrefabStageUtility.GetCurrentPrefabStage(); + bool inStage = stage != null; + editInPrefabStage = inStage; + + bool desired = EditorGUILayout.Toggle("Edit in isolated Prefab Stage", editInPrefabStage); + + string prefabPath = ResolvePrefabPathForEdit(); + using (new EditorGUI.DisabledScope(string.IsNullOrEmpty(prefabPath) && !inStage)) + { + 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); + } + + 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() + { + 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; + } + + void ApplyPrefabStage(bool wantStage, string prefabPath, bool currentlyInStage) + { + if (!wantStage) + { + if (currentlyInStage) StageUtility.GoToMainStage(); + UvtLog.Info("[LightmapUV] Switched to main stage."); + requestRepaint?.Invoke(); + return; + } + if (string.IsNullOrEmpty(prefabPath)) + { + UvtLog.Warn("[LightmapUV] No prefab asset path resolved for current LODGroup."); + return; + } + 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(); + } + + void DrawBuildGenerateLods() + { + EditorGUILayout.LabelField("Generate LODs", EditorStyles.miniBoldLabel); + + buildLodCount = EditorGUILayout.IntSlider("Count (new)", buildLodCount, 1, 4); + for (int i = 0; i < buildLodCount && i < buildLodRatios.Length; i++) + { + 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); + } + + 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; + } + + void ExecBuildGenerateLods() + { + 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(); + } + + void DrawBuildValidate() + { + EditorGUILayout.LabelField("Validate", EditorStyles.miniBoldLabel); + + 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(); + + if (buildIssues == null) return; + if (buildIssues.Count == 0) + { + EditorGUILayout.HelpBox("No issues found.", MessageType.Info); + 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); + + foreach (BuildValidator.IssueGroup grp in System.Enum.GetValues(typeof(BuildValidator.IssueGroup))) + { + 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(); + } + } + } + + void DrawBuildSave() + { + EditorGUILayout.LabelField("Save", EditorStyles.miniBoldLabel); + + 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(); + } + + void ExecBuildSave(bool overwriteSource) + { + 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; + } + + var hubs = Resources.FindObjectsOfTypeAll(); + var hub = hubs != null && hubs.Length > 0 ? hubs[0] : null; + var transferTool = hub != null ? hub.FindTool() : null; + if (transferTool == null) + { + UvtLog.Error("[Build] UV2 Transfer tool not found — cannot export FBX."); + return; + } + // 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; + } + // ═══════════════════════════════════════════════════════════ // LOD management section // ═══════════════════════════════════════════════════════════ @@ -874,6 +1163,20 @@ void DrawLodManagementSection() 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) + { + 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))) + { + RegenerateLod(li); + return; // UI invalidated + } + GUI.backgroundColor = Color.white; + } + // Remove LOD button if (lods.Length > 1) { @@ -925,10 +1228,7 @@ void DrawLodManagementSection() } if (changed) - { - Undo.RecordObject(ctx.LodGroup, "Edit LOD Transitions"); - ctx.LodGroup.SetLODs(lods); - } + LodGroupUtility.ApplyLods(ctx.LodGroup, lods); // Add LOD button EditorGUILayout.Space(4); @@ -943,16 +1243,14 @@ void AddLodLevel() { if (ctx.LodGroup == null) return; - Undo.RecordObject(ctx.LodGroup, "Add LOD Level"); var lods = ctx.LodGroup.GetLODs(); var newLods = new LOD[lods.Length + 1]; System.Array.Copy(lods, newLods, lods.Length); - // New LOD with lower transition than the last one float lastTrans = lods.Length > 0 ? lods[lods.Length - 1].screenRelativeTransitionHeight : 0.5f; newLods[lods.Length] = new LOD(lastTrans * 0.5f, new Renderer[0]); - ctx.LodGroup.SetLODs(newLods); + LodGroupUtility.ApplyLods(ctx.LodGroup, newLods); ctx.Refresh(ctx.LodGroup); requestRepaint?.Invoke(); UvtLog.Info($"Added LOD{lods.Length} (transition: {lastTrans * 0.5f:F3})"); @@ -962,7 +1260,6 @@ void RemoveLodLevel(int lodIndex) { if (ctx.LodGroup == null) return; - Undo.RecordObject(ctx.LodGroup, "Remove LOD Level"); var lods = ctx.LodGroup.GetLODs(); if (lodIndex < 0 || lodIndex >= lods.Length) return; @@ -973,13 +1270,115 @@ void RemoveLodLevel(int lodIndex) newLods[j++] = lods[i]; } - ctx.LodGroup.SetLODs(newLods); + LodGroupUtility.ApplyLods(ctx.LodGroup, newLods); ctx.LodGroup.RecalculateBounds(); ctx.Refresh(ctx.LodGroup); requestRepaint?.Invoke(); UvtLog.Info($"Removed LOD{lodIndex}"); } + // 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) + { + 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; + } + + 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; + } + + // Index LOD0 source meshes by stripped base name. + var sourceByKey = new System.Collections.Generic.Dictionary(); + foreach (var r in lod0) + { + 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 (sourceByKey.Count == 0) + { + UvtLog.Warn($"[LOD] Regenerate LOD{lodIndex}: no LOD0 source meshes found."); + return; + } + + // 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, + }; + + int regenerated = 0; + try + { + foreach (var r in lodN) + { + 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}"); + continue; + } + res.simplifiedMesh.name = sourceMesh.name + "_LOD" + lodIndex; + + Undo.RecordObject(mf, "Regenerate LOD"); + mf.sharedMesh = res.simplifiedMesh; + regenerated++; + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + 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)."); + } + } + void MoveRendererBetweenLods(Renderer renderer, int fromLod, int toLod) { if (ctx.LodGroup == null) return; @@ -999,6 +1398,11 @@ void MoveRendererBetweenLods(Renderer renderer, int fromLod, int toLod) lods[toLod].renderers = dstList.ToArray(); ctx.LodGroup.SetLODs(lods); + // Persist the LOD-array change on prefab instances; otherwise the + // move survives only in the live scene and is lost on scene reload + // or prefab reapply. + if (PrefabUtility.IsPartOfPrefabInstance(ctx.LodGroup)) + PrefabUtility.RecordPrefabInstancePropertyModifications(ctx.LodGroup); ctx.Refresh(ctx.LodGroup); requestRepaint?.Invoke(); UvtLog.Info($"Moved {renderer.name}: LOD{fromLod} -> LOD{toLod}"); @@ -1369,7 +1773,7 @@ void FixSplitByMaterial() { if (splitCandidates == null) return; - Undo.SetCurrentGroupName("Model Builder: Split by Material"); + Undo.SetCurrentGroupName("Prefab Builder: Split by Material"); int undoGroup = Undo.GetCurrentGroup(); int split = 0; @@ -1446,7 +1850,7 @@ void FixMerge() { if (mergeCandidates == null) return; - Undo.SetCurrentGroupName("Model Builder: Merge"); + Undo.SetCurrentGroupName("Prefab Builder: Merge"); int undoGroup = Undo.GetCurrentGroup(); int merged = 0; @@ -1507,6 +1911,13 @@ void FixMerge() Undo.RecordObject(firstEntry.meshFilter, "Merge"); firstEntry.meshFilter.sharedMesh = mergedMesh; + // Record the LODGroup before rewriting its renderer arrays. + // Without this, undoing the merge restores the destroyed + // GameObjects (Undo.DestroyObjectImmediate is undoable) but + // leaves the LODGroup pointing at the merged renderer list, + // so the restored objects fall out of LOD switching. + Undo.RecordObject(ctx.LodGroup, "Merge"); + // Update LODGroup renderers var lods = ctx.LodGroup.GetLODs(); for (int li = 0; li < lods.Length; li++) @@ -1530,6 +1941,8 @@ void FixMerge() lods[li].renderers = renderers.ToArray(); } ctx.LodGroup.SetLODs(lods); + if (PrefabUtility.IsPartOfPrefabInstance(ctx.LodGroup)) + PrefabUtility.RecordPrefabInstancePropertyModifications(ctx.LodGroup); foreach (var go in destroyList) { diff --git a/Editor/Tools/ModelBuilderTool.cs.meta b/Editor/Tools/PrefabBuilderTool.cs.meta similarity index 100% rename from Editor/Tools/ModelBuilderTool.cs.meta rename to Editor/Tools/PrefabBuilderTool.cs.meta diff --git a/Editor/Tools/Uv0OptimizeTool.cs b/Editor/Tools/Uv0OptimizeTool.cs deleted file mode 100644 index 02b68e39..00000000 --- a/Editor/Tools/Uv0OptimizeTool.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Uv0OptimizeTool.cs — Stub: UV0 repack / optimization. -// Placeholder for future implementation. - -using System; -using System.Collections.Generic; -using UnityEditor; - -namespace SashaRX.UnityMeshLab -{ - public class Uv0OptimizeTool : IUvTool - { - UvToolContext ctx; - UvCanvasView canvas; - Action requestRepaint; - - public string ToolName => "UV0 Optimize"; - public string ToolId => "uv0_optimize"; - public int ToolOrder => 20; - - public Action RequestRepaint { set => requestRepaint = value; } - - public void OnActivate(UvToolContext ctx, UvCanvasView canvas) - { - this.ctx = ctx; - this.canvas = canvas; - } - - public void OnDeactivate() { } - public void OnRefresh() { } - - public void OnDrawSidebar() - { - EditorGUILayout.Space(8); - EditorGUILayout.LabelField("UV0 Optimize", EditorStyles.boldLabel); - EditorGUILayout.Space(4); - EditorGUILayout.HelpBox( - "UV0 repack and optimization — analyze UV0 quality, fix overlaps, " + - "optimize texel density.\n\n" + - "Not yet implemented.", - MessageType.Info); - } - - public void OnDrawToolbarExtra() { } - public void OnDrawStatusBar() { } - - public void OnDrawCanvasOverlay(UvCanvasView canvas, float cx, float cy, float sz) { } - - public IEnumerable GetFillModes() - { - yield return new UvCanvasView.FillModeEntry { name = "Shells" }; - yield return new UvCanvasView.FillModeEntry { name = "Overlap" }; - yield return new UvCanvasView.FillModeEntry { name = "Stretch" }; - } - - public void OnSceneGUI(SceneView sv) { } - } -} diff --git a/Editor/Tools/UvPackHierarchyTool.cs b/Editor/Tools/UvPackHierarchyTool.cs new file mode 100644 index 00000000..b005ac6b --- /dev/null +++ b/Editor/Tools/UvPackHierarchyTool.cs @@ -0,0 +1,610 @@ +// UvPackHierarchyTool.cs — Per-mesh UV1 pack/repack across a GameObject +// subtree, without requiring a LODGroup. For every included MeshRenderer +// under the selected root, clones the mesh and runs XatlasRepack.RepackSingle +// to write a fresh unique UV1 atlas. Apply swaps the clone into the scene; +// "Overwrite Selected FBX" forwards to LightmapTransferTool's hierarchy +// export overload with channel=1. + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEditor; + +namespace SashaRX.UnityMeshLab +{ + public class UvPackHierarchyTool : IUvTool + { + UvToolContext ctx; + UvCanvasView canvas; + Action requestRepaint; + + public string ToolName => "UV1 Hierarchy"; + public string ToolId => "uv1_hierarchy"; + public int ToolOrder => 40; + + public Action RequestRepaint { set => requestRepaint = value; } + + // ── State ── + GameObject hierarchyRoot; + List hierarchyEntries = new List(); + GameObject lastSelection; + bool entriesBuilt; + bool meshesFoldout = true; + Vector2 meshesScroll; + bool fbxOverwriteFoldout = true; + Vector2 fbxOverwriteScroll; + Dictionary fbxOverwriteMap = new Dictionary(); + int listVisibleRows = 8; + + // Pack options + int resolution = 1024; + int shellPadding = 2; + int borderPadding = 0; + + // Per-renderer packed working copies. + // Key = entry.fbxMesh (original FBX sub-asset ref), Value = cloned mesh + // with fresh UV1 ready to apply. + Dictionary packedMeshes = new Dictionary(); + Dictionary packedResults = new Dictionary(); + + // Independent backup of scene MeshFilters that had their sharedMesh + // swapped to a packed clone. Survives hierarchyEntries rebuilds so + // Restore still finds the applied set after the user changes + // Selection (which would otherwise repopulate hierarchyEntries and + // orphan the old refs). + struct AppliedBackup + { + public MeshFilter mf; + public Mesh originalFbxMesh; + public Mesh appliedClone; + } + List appliedBackups = new List(); + + bool packedAppliedToScene => appliedBackups.Count > 0; + + // ── Lifecycle ── + + public void OnActivate(UvToolContext ctx, UvCanvasView canvas) + { + this.ctx = ctx; + this.canvas = canvas; + var ps = MeshLabProjectSettings.Instance; + resolution = ps.atlasResolution > 0 ? ps.atlasResolution : 1024; + shellPadding = ps.shellPaddingPx; + borderPadding = ps.borderPaddingPx; + EditorApplication.hierarchyChanged += OnEditorHierarchyChanged; + } + + public void OnDeactivate() + { + EditorApplication.hierarchyChanged -= OnEditorHierarchyChanged; + RestoreScene(); + DestroyPackedMeshes(); + hierarchyEntries.Clear(); + hierarchyRoot = null; + lastSelection = null; + entriesBuilt = false; + fbxOverwriteMap.Clear(); + } + + public void OnRefresh() + { + // ExecutePack re-selects the first packed child so the canvas shows + // the packed UV1; that selection change routes back here via the + // Hub. Destroying the packed meshes now would wipe results the user + // still needs to Apply / Overwrite. Skip the destructive cleanup + // while the new selection is still inside the packed hierarchy root + // (mirrors the guard in RefreshEntriesIfNeeded). + var sel = Selection.activeGameObject; + if (packedMeshes.Count > 0 && hierarchyRoot != null && sel != null && + (sel == hierarchyRoot || sel.transform.IsChildOf(hierarchyRoot.transform))) + return; + + RestoreScene(); + DestroyPackedMeshes(); + } + + void OnEditorHierarchyChanged() => entriesBuilt = false; + + // ── Entries ── + + void RefreshEntriesIfNeeded() + { + var sel = Selection.activeGameObject; + if (entriesBuilt && sel == lastSelection) return; + + // Row-click sets Selection to a descendant so the canvas shows + // that mesh's UV1 — keep the original root and list intact. + if (entriesBuilt && hierarchyRoot != null && sel != null && + (sel == hierarchyRoot || sel.transform.IsChildOf(hierarchyRoot.transform))) + { + lastSelection = sel; + return; + } + + RefreshEntries(); + lastSelection = sel; + entriesBuilt = true; + } + + void RefreshEntries() + { + // Snapshot user include toggles so selection changes don't reset + // checkboxes. + var prevInclude = new Dictionary(); + foreach (var e in hierarchyEntries) + if (e?.renderer != null) + prevInclude[e.renderer.GetInstanceID()] = e.include; + + hierarchyEntries.Clear(); + hierarchyRoot = Selection.activeGameObject; + if (hierarchyRoot == null) return; + + foreach (var r in hierarchyRoot.GetComponentsInChildren(true)) + { + if (r == null || !r.enabled || !r.gameObject.activeInHierarchy) continue; + if (MeshHygieneUtility.IsCollisionNodeName(r.name)) continue; + var mf = r.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + if (mf.sharedMesh.vertexCount == 0) continue; + + var entry = new MeshEntry + { + lodIndex = 0, + renderer = r, + meshFilter = mf, + originalMesh = mf.sharedMesh, + fbxMesh = mf.sharedMesh, + meshGroupKey = UvToolContext.ExtractGroupKey(r.name) + }; + if (prevInclude.TryGetValue(r.GetInstanceID(), out var prev)) + entry.include = prev; + hierarchyEntries.Add(entry); + } + } + + void SyncFbxOverwriteMap() + { + var current = new HashSet(); + foreach (var e in hierarchyEntries) + { + if (e == null || !e.include || e.fbxMesh == null) continue; + string p = AssetDatabase.GetAssetPath(e.fbxMesh); + if (string.IsNullOrEmpty(p)) continue; + if (!p.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase)) continue; + current.Add(p); + if (!fbxOverwriteMap.ContainsKey(p)) + fbxOverwriteMap[p] = true; + } + var stale = fbxOverwriteMap.Keys.Where(k => !current.Contains(k)).ToList(); + foreach (var k in stale) fbxOverwriteMap.Remove(k); + } + + // ── UI ── + + public void OnDrawSidebar() + { + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("UV1 Pack (Hierarchy)", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + RefreshEntriesIfNeeded(); + if (hierarchyRoot == null || hierarchyEntries.Count == 0) + { + EditorGUILayout.HelpBox( + "Select a root GameObject that has active MeshRenderer descendants.", + MessageType.Info); + return; + } + + EditorGUILayout.LabelField( + $"Root: {hierarchyRoot.name} ({hierarchyEntries.Count} meshes)", + EditorStyles.miniLabel); + + DrawMeshList(); + DrawPackSettings(); + + EditorGUILayout.Space(8); + DrawPackActions(); + + if (packedMeshes.Count == 0) return; + + DrawResults(); + DrawApplyRow(); + DrawFbxOverwritePicker(); + } + + void DrawMeshList() + { + int included = 0; + foreach (var e in hierarchyEntries) if (e.include) included++; + + meshesFoldout = EditorGUILayout.Foldout(meshesFoldout, + $"Meshes ({included} / {hierarchyEntries.Count} included)", true); + if (!meshesFoldout) return; + + EditorGUI.indentLevel++; + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("All", EditorStyles.miniButtonLeft)) + foreach (var e in hierarchyEntries) e.include = true; + if (GUILayout.Button("None", EditorStyles.miniButtonMid)) + foreach (var e in hierarchyEntries) e.include = false; + if (GUILayout.Button("Invert", EditorStyles.miniButtonRight)) + foreach (var e in hierarchyEntries) e.include = !e.include; + EditorGUILayout.EndHorizontal(); + + listVisibleRows = EditorGUILayout.IntSlider( + new GUIContent("Rows", "Number of rows visible before scrolling."), + listVisibleRows, 3, 30); + + float rowHeight = EditorGUIUtility.singleLineHeight + 4f; + float listHeight = Mathf.Min(hierarchyEntries.Count, listVisibleRows) * rowHeight + 6f; + meshesScroll = EditorGUILayout.BeginScrollView(meshesScroll, + alwaysShowHorizontal: false, alwaysShowVertical: false, + GUIStyle.none, GUI.skin.verticalScrollbar, GUI.skin.scrollView, + GUILayout.Height(listHeight)); + + foreach (var e in hierarchyEntries) + { + if (e == null || e.renderer == null) continue; + bool hasUv1 = MeshHasUv1(e.originalMesh); + string fbxName = e.fbxMesh != null + ? System.IO.Path.GetFileName(AssetDatabase.GetAssetPath(e.fbxMesh)) + : null; + string tooltip = (string.IsNullOrEmpty(fbxName) ? "" : fbxName + "\n") + + (hasUv1 ? "Has UV1 → will be repacked." : "No UV1 → unwrap from UV0.") + + "\nClick to ping in Hierarchy"; + + EditorGUILayout.BeginHorizontal(); + bool next = EditorGUILayout.Toggle(e.include, GUILayout.Width(22)); + if (next != e.include) e.include = next; + GUILayout.Space(4); + string rowLabel = hasUv1 ? e.renderer.name : e.renderer.name + " *"; + if (GUILayout.Button(new GUIContent(rowLabel, tooltip), EditorStyles.label)) + { + // Select (not just ping) so the shared UV canvas refreshes + // onto this mesh via ctx.RefreshStandalone and shows UV1. + Selection.activeGameObject = e.renderer.gameObject; + EditorGUIUtility.PingObject(e.renderer.gameObject); + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + EditorGUI.indentLevel--; + } + + void DrawPackSettings() + { + EditorGUILayout.Space(4); + resolution = EditorGUILayout.IntField( + new GUIContent("Atlas Resolution", "Target pixel resolution for the UV1 atlas."), + resolution); + if (resolution < 64) resolution = 64; + shellPadding = EditorGUILayout.IntSlider( + new GUIContent("Shell Padding", "Pixels between shells."), + shellPadding, 0, 16); + borderPadding = EditorGUILayout.IntSlider( + new GUIContent("Border Padding", "Pixels of atlas-edge inset."), + borderPadding, 0, 16); + } + + void DrawPackActions() + { + var bgc = GUI.backgroundColor; + EditorGUILayout.BeginHorizontal(); + GUI.backgroundColor = new Color(.4f, .8f, .4f); + if (GUILayout.Button("Pack UV1", GUILayout.Height(28))) + ExecutePack(); + GUI.backgroundColor = new Color(.9f, .3f, .3f); + using (new EditorGUI.DisabledScope(packedMeshes.Count == 0)) + { + if (GUILayout.Button("Clear", GUILayout.Height(28))) + { + RestoreScene(); + DestroyPackedMeshes(); + requestRepaint?.Invoke(); + } + } + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); + } + + void DrawResults() + { + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("Results", EditorStyles.boldLabel); + int okCount = 0, failCount = 0, totalShells = 0; + foreach (var r in packedResults.Values) + { + if (r.ok) okCount++; else failCount++; + totalShells += r.shellCount; + } + EditorGUILayout.LabelField( + $" Packed: {okCount}, Failed: {failCount}, Shells: {totalShells}", + EditorStyles.miniLabel); + if (failCount > 0) + { + foreach (var kvp in packedResults) + if (!kvp.Value.ok) + EditorGUILayout.LabelField($" • {kvp.Key.name}: {kvp.Value.error}", EditorStyles.miniLabel); + } + } + + void DrawApplyRow() + { + var bgc = GUI.backgroundColor; + EditorGUILayout.BeginHorizontal(); + GUI.backgroundColor = packedAppliedToScene ? new Color(.3f, .7f, 1f) : new Color(.3f, .85f, .4f); + if (GUILayout.Button(packedAppliedToScene ? "Applied ✓" : "Apply to Mesh", GUILayout.Height(24))) + ApplyToScene(); + GUI.backgroundColor = new Color(.6f, .6f, .6f); + using (new EditorGUI.DisabledScope(!packedAppliedToScene)) + { + if (GUILayout.Button("Restore", GUILayout.Height(24))) + RestoreScene(); + } + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); + } + + void DrawFbxOverwritePicker() + { + SyncFbxOverwriteMap(); + + if (fbxOverwriteMap.Count == 0) + { + EditorGUILayout.HelpBox( + "No included FBX-backed meshes.", + MessageType.Info); + return; + } + + int checkedCount = 0; + foreach (var v in fbxOverwriteMap.Values) if (v) checkedCount++; + + fbxOverwriteFoldout = EditorGUILayout.Foldout(fbxOverwriteFoldout, + $"Overwrite FBX ({checkedCount} / {fbxOverwriteMap.Count} selected)", true); + if (!fbxOverwriteFoldout) return; + + EditorGUI.indentLevel++; + var paths = fbxOverwriteMap.Keys.OrderBy(p => p).ToList(); + + float rowHeight = EditorGUIUtility.singleLineHeight + 4f; + float listHeight = Mathf.Min(paths.Count, listVisibleRows) * rowHeight + 6f; + fbxOverwriteScroll = EditorGUILayout.BeginScrollView(fbxOverwriteScroll, + alwaysShowHorizontal: false, alwaysShowVertical: false, + GUIStyle.none, GUI.skin.verticalScrollbar, GUI.skin.scrollView, + GUILayout.Height(listHeight)); + + foreach (var path in paths) + { + string label = System.IO.Path.GetFileName(path); + bool cur = fbxOverwriteMap[path]; + EditorGUILayout.BeginHorizontal(); + bool next = EditorGUILayout.Toggle(cur, GUILayout.Width(22)); + if (next != cur) fbxOverwriteMap[path] = next; + GUILayout.Space(4); + if (GUILayout.Button(new GUIContent(label, path + "\nClick to ping in Project"), + EditorStyles.label)) + { + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) EditorGUIUtility.PingObject(asset); + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + + var bgc = GUI.backgroundColor; + GUI.backgroundColor = checkedCount > 0 && packedAppliedToScene ? new Color(.4f, .7f, .95f) : Color.white; + using (new EditorGUI.DisabledScope(checkedCount == 0 || !packedAppliedToScene)) + { + if (GUILayout.Button($"Overwrite Selected FBX ({checkedCount})", GUILayout.Height(22))) + OverwriteSelectedFbx(); + } + GUI.backgroundColor = bgc; + if (!packedAppliedToScene) + EditorGUILayout.LabelField(" Apply to Mesh first.", EditorStyles.miniLabel); + EditorGUI.indentLevel--; + } + + // ── Pack ── + + void ExecutePack() + { + RestoreScene(); + DestroyPackedMeshes(); + + var entries = hierarchyEntries.Where(e => e.include && e.renderer != null).ToList(); + if (entries.Count == 0) + { + UvtLog.Warn("[UV1] No meshes selected."); + return; + } + + var opts = new RepackOptions + { + padding = (uint)Mathf.Max(0, shellPadding), + borderPadding = (uint)Mathf.Max(0, borderPadding), + resolution = (uint)Mathf.Max(64, resolution), + texelsPerUnit = 0f, + bilinear = true, + blockAlign = false, + bruteForce = false, + }; + + int ok = 0, fail = 0; + foreach (var e in entries) + { + var src = e.fbxMesh ?? e.originalMesh; + if (src == null) continue; + if (packedMeshes.ContainsKey(src)) continue; // dedup shared sub-asset + + var clone = UnityEngine.Object.Instantiate(src); + clone.name = src.name; + clone.hideFlags = HideFlags.HideAndDontSave; + + var res = XatlasRepack.RepackSingle(clone, opts); + packedMeshes[src] = clone; + packedResults[src] = res; + if (res.ok) ok++; else + { + fail++; + UvtLog.Warn($"[UV1] Pack failed for '{src.name}': {res.error}"); + } + } + + UvtLog.Info($"[UV1] Packed {ok} mesh(es), failed {fail}."); + + // Auto-select the first packed renderer so the shared UV canvas + // populates via UvToolHub.OnSelectionChange → ctx.RefreshStandalone. + // Without this, a parent GameObject selected as root leaves ctx + // empty (Refresh(null) path) and the canvas shows "No meshes". + if (ok > 0) + { + foreach (var e in entries) + { + var src = e.fbxMesh ?? e.originalMesh; + if (src == null) continue; + if (!packedResults.TryGetValue(src, out var res) || !res.ok) continue; + if (e.renderer == null) continue; + if (Selection.activeGameObject != e.renderer.gameObject) + Selection.activeGameObject = e.renderer.gameObject; + break; + } + } + + requestRepaint?.Invoke(); + } + + void ApplyToScene() + { + if (packedMeshes.Count == 0) return; + // Clear previous apply state (restore into old scene refs first) + // so re-apply on a different hierarchy doesn't stack backups. + RestoreScene(); + + int applied = 0; + foreach (var e in hierarchyEntries) + { + if (!e.include || e.renderer == null || e.meshFilter == null) continue; + var src = e.fbxMesh ?? e.originalMesh; + if (src == null) continue; + if (!packedMeshes.TryGetValue(src, out var clone) || clone == null) continue; + if (!packedResults.TryGetValue(src, out var res) || !res.ok) continue; + + Undo.RecordObject(e.meshFilter, "Apply UV1 Pack"); + var original = e.meshFilter.sharedMesh; + e.meshFilter.sharedMesh = clone; + e.originalMesh = clone; // snapshot lookup uses originalMesh + appliedBackups.Add(new AppliedBackup + { + mf = e.meshFilter, + originalFbxMesh = original, + appliedClone = clone + }); + applied++; + } + UvtLog.Info($"[UV1] Applied to {applied} mesh(es)."); + SceneView.RepaintAll(); + requestRepaint?.Invoke(); + } + + void RestoreScene() + { + if (appliedBackups.Count == 0) return; + int restored = 0; + foreach (var b in appliedBackups) + { + if (b.mf == null) continue; + // Only roll back if the MeshFilter still holds our applied + // clone; external tooling may have swapped it already. + if (b.mf.sharedMesh != b.appliedClone) continue; + if (b.originalFbxMesh == null) continue; + Undo.RecordObject(b.mf, "Restore Mesh"); + b.mf.sharedMesh = b.originalFbxMesh; + restored++; + } + appliedBackups.Clear(); + // Also rewire any still-live hierarchyEntries so their + // originalMesh reflects the restored state (the entries may + // have been rebuilt against a different selection). + foreach (var e in hierarchyEntries) + if (e != null && e.meshFilter != null && e.meshFilter.sharedMesh != null) + e.originalMesh = e.meshFilter.sharedMesh; + if (restored > 0) UvtLog.Info($"[UV1] Restored {restored} mesh(es) to original."); + SceneView.RepaintAll(); + requestRepaint?.Invoke(); + } + + void DestroyPackedMeshes() + { + // If a clone is still assigned to a live MeshFilter (because the + // user walked away without Restore), swap it back before destroy + // to avoid leaving missing-mesh refs in the scene. + RestoreScene(); + foreach (var m in packedMeshes.Values) + if (m != null) UnityEngine.Object.DestroyImmediate(m); + packedMeshes.Clear(); + packedResults.Clear(); + } + + void OverwriteSelectedFbx() + { + var selected = fbxOverwriteMap.Where(kv => kv.Value).Select(kv => kv.Key).ToList(); + if (selected.Count == 0) return; + + string list = string.Join("\n", selected.Select(p => " • " + System.IO.Path.GetFileName(p))); + if (!EditorUtility.DisplayDialog( + "Overwrite Selected FBX", + $"Overwrite {selected.Count} FBX file(s)?\n\n{list}\n\nOnly UV channel 1 is updated. Topology stays unchanged.", + "Overwrite", "Cancel")) + return; + + var hub = Resources.FindObjectsOfTypeAll(); + if (hub.Length == 0) return; + var transferTool = hub[0].FindTool(); + if (transferTool == null) + { + UvtLog.Error("[UV1] LightmapTransferTool not found."); + return; + } + + foreach (var path in selected) + { + var entriesForPath = hierarchyEntries + .Where(e => e.include && e.fbxMesh != null && + string.Equals(AssetDatabase.GetAssetPath(e.fbxMesh), path, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (entriesForPath.Count == 0) + { + UvtLog.Warn($"[UV1] No hierarchy entries map to '{path}', skipping."); + continue; + } + // UV1-only re-save: ExportVertexColorsToFbx unconditionally + // seeds VertexColors into the intent and would clobber any + // authored vertex colors in the source FBX. Use the explicit + // isolated-channel API instead. + transferTool.ExportIsolatedChannelsToFbx(path, entriesForPath, FbxExportIntent.UV1); + } + } + + // ── Helpers ── + + static bool MeshHasUv1(Mesh mesh) + { + if (mesh == null) return false; + var list = new List(); + mesh.GetUVs(1, list); + return list.Count == mesh.vertexCount && list.Count > 0; + } + + // ── Unused IUvTool members ── + + public void OnDrawToolbarExtra() { } + public void OnDrawStatusBar() { } + public void OnDrawCanvasOverlay(UvCanvasView canvas, float cx, float cy, float sz) { } + public IEnumerable GetFillModes() { yield break; } + public void OnSceneGUI(SceneView sv) { } + } +} diff --git a/Editor/Tools/UvPackHierarchyTool.cs.meta b/Editor/Tools/UvPackHierarchyTool.cs.meta new file mode 100644 index 00000000..c722ad65 --- /dev/null +++ b/Editor/Tools/UvPackHierarchyTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d97e48a4e37a419da55fd4e94d80b972 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tools/VariantExportPipeline.cs b/Editor/Tools/VariantExportPipeline.cs new file mode 100644 index 00000000..3de1326f --- /dev/null +++ b/Editor/Tools/VariantExportPipeline.cs @@ -0,0 +1,399 @@ +// VariantExportPipeline.cs — bake → FBX export → prefab clone for solid color variants. +// Used by VertexColorBakingTool to produce {baseName}_{suffix}.fbx + matching prefab. + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class VariantExportPipeline + { + public enum ConflictPolicy { Overwrite, AutoIncrement, Cancel } + + public struct Variant + { + public Color color; + public string suffix; + } + + public struct Result + { + public bool ok; + public string error; + public string suffix; + public Color color; + public string fbxPath; + public string prefabPath; + } + + public static bool ValidateSuffix(string suffix, out string error) + { + error = null; + if (string.IsNullOrEmpty(suffix)) + { + error = "Suffix is empty."; + return false; + } + if (!System.Text.RegularExpressions.Regex.IsMatch(suffix, @"^[A-Za-z0-9_]+$")) + { + error = "Suffix may contain only letters, digits, and underscore."; + return false; + } + return true; + } + + // Paint mesh.colors32 with a uniform Color32 across every mesh variant + // of an entry (originalMesh, repackedMesh, transferredMesh, fbxMesh). + // Skips collision meshes via MeshHygieneUtility. Returns the number of + // distinct meshes painted. + public static int BakeSolidColorOnEntries(IEnumerable entries, Color color) + { + if (entries == null) return 0; + var color32 = (Color32)color; + var painted = new HashSet(); + + foreach (var entry in entries) + { + if (entry == null || entry.renderer == null || !entry.include) continue; + if (MeshHygieneUtility.IsCollisionNodeName(entry.renderer.name)) continue; + + var meshes = new[] { entry.originalMesh, entry.repackedMesh, entry.transferredMesh, entry.fbxMesh }; + foreach (var mesh in meshes) + { + if (mesh == null || mesh.vertexCount == 0) continue; + if (!painted.Add(mesh)) continue; + + Undo.RecordObject(mesh, "Bake Solid Color"); + var arr = new Color32[mesh.vertexCount]; + for (int i = 0; i < arr.Length; i++) arr[i] = color32; + mesh.colors32 = arr; + EditorUtility.SetDirty(mesh); + } + } + return painted.Count; + } + + // Capture the current vertex colors of every mesh BakeSolidColorOnEntries + // would paint, so the batch can be rolled back. A null value means the + // mesh had no colors32 and should be cleared on restore. + static Dictionary SnapshotEntryColors(IEnumerable entries) + { + var backup = new Dictionary(); + if (entries == null) return backup; + foreach (var entry in entries) + { + if (entry == null || entry.renderer == null || !entry.include) continue; + if (MeshHygieneUtility.IsCollisionNodeName(entry.renderer.name)) continue; + + var meshes = new[] { entry.originalMesh, entry.repackedMesh, entry.transferredMesh, entry.fbxMesh }; + foreach (var mesh in meshes) + { + if (mesh == null || mesh.vertexCount == 0) continue; + if (backup.ContainsKey(mesh)) continue; + var c = mesh.colors32; + backup[mesh] = (c != null && c.Length == mesh.vertexCount) ? c : null; + } + } + return backup; + } + + // Restore vertex colors captured by SnapshotEntryColors. + static void RestoreEntryColors(Dictionary backup) + { + if (backup == null) return; + foreach (var kv in backup) + { + var mesh = kv.Key; + if (mesh == null) continue; + Undo.RecordObject(mesh, "Restore Vertex Colors"); + mesh.colors32 = kv.Value ?? new Color32[mesh.vertexCount]; + EditorUtility.SetDirty(mesh); + } + } + + // Run a batch of variants against the same source FBX/prefab. Each + // variant must finish its export + import + prefab clone cycle + // before the next one starts — wrapping the loop in + // StartAssetEditing/StopAssetEditing would defer ImportAsset and + // SaveAndReimport, so BuildPrefabClone would observe the just- + // exported FBX as "no meshes" and fail. A single Refresh at the + // end keeps the project view in sync without batching imports. + public static IList ExportVariants( + LightmapTransferTool fbxExporter, + string sourceFbxPath, + GameObject sourcePrefab, + IList entries, + IList variants, + ConflictPolicy conflictPolicy) + { + var results = new List(); + + if (fbxExporter == null) + { + results.Add(Fail("", default, "Internal error: fbxExporter is null.")); + return results; + } + if (string.IsNullOrEmpty(sourceFbxPath)) + { + results.Add(Fail("", default, "Source FBX path is empty.")); + return results; + } + if (entries == null || entries.Count == 0) + { + results.Add(Fail("", default, "No mesh entries supplied.")); + return results; + } + if (variants == null || variants.Count == 0) + { + results.Add(Fail("", default, "No variants supplied.")); + return results; + } + + // Validate all suffixes upfront; one bad suffix shouldn't cancel + // the whole batch silently — surface every failure at once. + bool anyInvalid = false; + foreach (var v in variants) + { + if (!ValidateSuffix(v.suffix, out string err)) + { + results.Add(Fail(v.suffix, v.color, err)); + anyInvalid = true; + } + } + if (anyInvalid) return results; + + // Detect duplicate suffixes inside the batch — would either fight + // for the same output path or auto-increment unpredictably. + // Case-insensitive: case-insensitive filesystems (default on Windows + // and macOS) collapse "Red" and "red" to the same FBX path, which + // would silently clobber one variant's output with the other. + var dupes = variants + .GroupBy(v => v.suffix, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (dupes.Count > 0) + { + results.Add(Fail("", default, "Duplicate suffix(es) in batch: " + string.Join(", ", dupes))); + return results; + } + + // BakeSolidColorOnEntries paints the shared source meshes in place + // for each variant. Snapshot their original vertex colors up front + // and restore afterwards so the scene / working meshes aren't left + // wearing the last variant's solid color (and later exports don't + // start from that contaminated state). + var colorBackup = SnapshotEntryColors(entries); + try + { + foreach (var v in variants) + results.Add(ExportSingleVariant(fbxExporter, sourceFbxPath, sourcePrefab, entries, v, conflictPolicy)); + } + finally + { + RestoreEntryColors(colorBackup); + AssetDatabase.Refresh(); + } + return results; + } + + static Result ExportSingleVariant( + LightmapTransferTool fbxExporter, + string sourceFbxPath, + GameObject sourcePrefab, + IList entries, + Variant variant, + ConflictPolicy policy) + { + if (!ResolveOutputPaths(sourceFbxPath, sourcePrefab, variant.suffix, policy, + out string outFbxPath, out string outPrefabPath, out string err)) + return Fail(variant.suffix, variant.color, err); + + int painted = BakeSolidColorOnEntries(entries, variant.color); + if (painted == 0) + return Fail(variant.suffix, variant.color, "No paintable meshes (all collision, excluded, or empty)."); + + bool fbxOk = fbxExporter.ExportVertexColorsToFbxAs(sourceFbxPath, outFbxPath, entries); + if (!fbxOk) + return Fail(variant.suffix, variant.color, "FBX export failed (see Console)."); + + // Force-reimport so AssetDatabase exposes the new sub-meshes BEFORE + // we try to bind them into the prefab clone below. + AssetDatabase.ImportAsset(outFbxPath, ImportAssetOptions.ForceUpdate); + + string prefabPathOut = null; + if (sourcePrefab != null && !string.IsNullOrEmpty(outPrefabPath)) + { + try + { + BuildPrefabClone(sourcePrefab, outFbxPath, outPrefabPath); + prefabPathOut = outPrefabPath; + } + catch (Exception ex) + { + UvtLog.Error($"[VariantExport] Prefab clone failed for '{variant.suffix}': {ex.Message}"); + return new Result + { + ok = false, + error = "Prefab clone failed: " + ex.Message, + suffix = variant.suffix, + color = variant.color, + fbxPath = outFbxPath, + }; + } + } + + UvtLog.Info( + $"[VariantExport] '{variant.suffix}' done — FBX={outFbxPath}" + + (prefabPathOut != null ? $", Prefab={prefabPathOut}" : " (no prefab)")); + return new Result + { + ok = true, + suffix = variant.suffix, + color = variant.color, + fbxPath = outFbxPath, + prefabPath = prefabPathOut, + }; + } + + // Clone source prefab, swap every MeshFilter.sharedMesh to the matching + // sub-mesh in the new FBX (matched by name), unpack so the result is a + // standalone prefab (full clone, no variant relationship), and save. + static void BuildPrefabClone(GameObject sourcePrefab, string newFbxPath, string outPrefabPath) + { + var newMeshesByName = new Dictionary(StringComparer.Ordinal); + foreach (var asset in AssetDatabase.LoadAllAssetsAtPath(newFbxPath)) + { + if (asset is Mesh m && !newMeshesByName.ContainsKey(m.name)) + newMeshesByName[m.name] = m; + } + if (newMeshesByName.Count == 0) + throw new Exception($"New FBX '{newFbxPath}' contains no meshes."); + + var clone = (GameObject)PrefabUtility.InstantiatePrefab(sourcePrefab); + if (clone == null) + throw new Exception("PrefabUtility.InstantiatePrefab returned null."); + + try + { + // Unpack so we can save the result as a brand-new prefab root + // instead of a variant of the source. + PrefabUtility.UnpackPrefabInstance(clone, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + + int swapped = 0; + int unmatched = 0; + foreach (var mf in clone.GetComponentsInChildren(true)) + { + if (mf == null || mf.sharedMesh == null) continue; + if (newMeshesByName.TryGetValue(mf.sharedMesh.name, out var newMesh)) + { + mf.sharedMesh = newMesh; + swapped++; + } + else + { + unmatched++; + } + } + if (swapped == 0) + throw new Exception("No mesh references could be remapped to the new FBX (sub-mesh names must match)."); + if (unmatched > 0) + UvtLog.Warn($"[VariantExport] {unmatched} MeshFilter(s) in prefab clone had no matching sub-mesh in '{newFbxPath}' — left pointing at original."); + + PrefabUtility.SaveAsPrefabAsset(clone, outPrefabPath); + } + finally + { + UnityEngine.Object.DestroyImmediate(clone); + } + } + + static bool ResolveOutputPaths( + string sourceFbxPath, + GameObject sourcePrefab, + string suffix, + ConflictPolicy policy, + out string outFbxPath, + out string outPrefabPath, + out string error) + { + outFbxPath = null; + outPrefabPath = null; + error = null; + + string fbxDir = System.IO.Path.GetDirectoryName(sourceFbxPath)?.Replace('\\', '/'); + string fbxBase = System.IO.Path.GetFileNameWithoutExtension(sourceFbxPath); + if (string.IsNullOrEmpty(fbxDir) || string.IsNullOrEmpty(fbxBase)) + { + error = $"Cannot derive output paths from source FBX '{sourceFbxPath}'."; + return false; + } + + string prefabDir = null, prefabBase = null; + if (sourcePrefab != null) + { + string prefabSrcPath = AssetDatabase.GetAssetPath(sourcePrefab); + if (!string.IsNullOrEmpty(prefabSrcPath) && + prefabSrcPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + prefabDir = System.IO.Path.GetDirectoryName(prefabSrcPath)?.Replace('\\', '/'); + prefabBase = System.IO.Path.GetFileNameWithoutExtension(prefabSrcPath); + } + } + + string MakeFbx(string s) => $"{fbxDir}/{fbxBase}_{s}.fbx"; + string MakePrefab(string s) => prefabBase == null ? null : $"{prefabDir}/{prefabBase}_{s}.prefab"; + + string finalSuffix = suffix; + if (policy == ConflictPolicy.Cancel) + { + if (System.IO.File.Exists(MakeFbx(finalSuffix))) + { + error = $"Output FBX exists: {MakeFbx(finalSuffix)}"; + return false; + } + var pp = MakePrefab(finalSuffix); + if (pp != null && System.IO.File.Exists(pp)) + { + error = $"Output Prefab exists: {pp}"; + return false; + } + } + else if (policy == ConflictPolicy.AutoIncrement) + { + // Test the current candidate, then bump if it conflicts. The + // exhaustion check sits BEFORE the bump so the final candidate + // (`{suffix}_99`) actually gets tested instead of being prepared + // and then immediately discarded. + int attempt = 2; + while (true) + { + bool fbxConflict = System.IO.File.Exists(MakeFbx(finalSuffix)); + var pp = MakePrefab(finalSuffix); + bool prefabConflict = pp != null && System.IO.File.Exists(pp); + if (!fbxConflict && !prefabConflict) break; + if (attempt >= 100) + { + error = "Auto-increment exhausted after 99 attempts."; + return false; + } + finalSuffix = $"{suffix}_{attempt}"; + attempt++; + } + } + // Overwrite: nothing to check; caller accepted clobber. + + outFbxPath = MakeFbx(finalSuffix); + outPrefabPath = MakePrefab(finalSuffix); + return true; + } + + static Result Fail(string suffix, Color color, string error) + { + return new Result { ok = false, error = error, suffix = suffix, color = color }; + } + } +} diff --git a/Editor/Tools/VariantExportPipeline.cs.meta b/Editor/Tools/VariantExportPipeline.cs.meta new file mode 100644 index 00000000..8c8d8455 --- /dev/null +++ b/Editor/Tools/VariantExportPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ed0f65b6efef4888a0bbb8254d574411 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tools/VertexAOTool.cs b/Editor/Tools/VertexColorBakingTool.cs similarity index 50% rename from Editor/Tools/VertexAOTool.cs rename to Editor/Tools/VertexColorBakingTool.cs index f823a208..5e34b898 100644 --- a/Editor/Tools/VertexAOTool.cs +++ b/Editor/Tools/VertexColorBakingTool.cs @@ -1,4 +1,5 @@ -// VertexAOTool.cs — Vertex AO baking tool (IUvTool tab). +// VertexColorBakingTool.cs — Vertex color baking tool (IUvTool tab). +// Bake modes: AO (GPU/CPU BVH ray tracing) and Solid Color (uniform fill). // GPU: non-blocking async BVH ray tracing via compute shader. // CPU: synchronous BVH ray tracing with Parallel.For. @@ -12,14 +13,14 @@ namespace SashaRX.UnityMeshLab { - public class VertexAOTool : IUvTool + public class VertexColorBakingTool : IUvTool { UvToolContext ctx; UvCanvasView canvas; Action requestRepaint; - public string ToolName => "Vertex AO"; - public string ToolId => "vertex_ao"; + public string ToolName => "Vertex Color Baking"; + public string ToolId => "vertex_color_baking"; public int ToolOrder => 50; public Action RequestRepaint { set => requestRepaint = value; } @@ -79,6 +80,31 @@ public class VertexAOTool : IUvTool bool applySelectedSubmeshOnly; int selectedSubmeshIndex; + // ── Bake Kind (AO vs Solid Color) ── + internal enum BakeKind { AO = 0, SolidColor = 1 } + BakeKind bakeKind = BakeKind.AO; + static readonly string[] bakeKindLabels = { "AO", "Solid Color" }; + + // ── Solid Color variants (batch export) ── + List variants = new List + { + new VariantExportPipeline.Variant { color = Color.red, suffix = "Red" } + }; + + // ── Hierarchy mode (no LODGroup, all descendant MeshRenderers as one batch) ── + bool hierarchyMode; + GameObject hierarchyRoot; + List hierarchyEntries = new List(); + GameObject lastHierarchySelection; + bool lastHierarchyMode; + bool hierarchyEntriesBuilt; + bool hierarchyEntriesFoldout = true; + Vector2 hierarchyEntriesScroll; + bool fbxOverwriteFoldout = true; + Vector2 fbxOverwriteScroll; + Dictionary fbxOverwriteMap = new Dictionary(); + int listVisibleRows = 8; + // ── Results ── Dictionary bakedRawAO; // raw vertex AO (no face-area fix) Dictionary bakedFaceAreaAO; // face-area corrected AO @@ -108,7 +134,7 @@ public class VertexAOTool : IUvTool // ── Lifecycle ── - internal static VertexAOTool ActiveInstance { get; private set; } + internal static VertexColorBakingTool ActiveInstance { get; private set; } internal static AOTargetChannel? LastAppliedTargetChannel { get; private set; } class LodBakeBatch @@ -121,6 +147,10 @@ class LodBakeBatch public int renderableOccluderCount; public int colliderOccluderCount; public bool usedSidecarCollisionFallback; + // Optional: renderers already folded into occluderMeshes manually + // (rebake-selected adds hierarchy siblings). CollectAdditionalOccluders + // skips them to avoid double-add. + public HashSet preAddedOccluderRendererIds; } public void OnActivate(UvToolContext ctx, UvCanvasView canvas) @@ -128,13 +158,21 @@ public void OnActivate(UvToolContext ctx, UvCanvasView canvas) this.ctx = ctx; this.canvas = canvas; ActiveInstance = this; + EditorApplication.hierarchyChanged += OnEditorHierarchyChanged; } public void OnDeactivate() { + EditorApplication.hierarchyChanged -= OnEditorHierarchyChanged; CancelGpuJob(); RestorePreview(); ClearResults(); + hierarchyEntries.Clear(); + hierarchyRoot = null; + lastHierarchySelection = null; + lastHierarchyMode = false; + hierarchyEntriesBuilt = false; + fbxOverwriteMap.Clear(); } public void OnRefresh() @@ -144,6 +182,198 @@ public void OnRefresh() ClearResults(); } + // Rebuild hierarchy entries only when selection / mode / root contents + // actually changed. OnGUI fires multiple times per frame, so + // unconditional refresh would churn GC on larger hierarchies. + // The hierarchyChanged event flips hierarchyEntriesBuilt to false on + // any scene mutation (add/remove/reparent/rename) — repaint rebuilds + // the list on the next tick, picking up the change automatically. + void RefreshHierarchyEntriesIfNeeded() + { + var sel = Selection.activeGameObject; + if (hierarchyEntriesBuilt && + hierarchyMode == lastHierarchyMode && + sel == lastHierarchySelection) + return; + + // Row-click sets Selection to a descendant so the canvas shows + // that mesh's UV — keep the original root and mesh list intact. + if (hierarchyEntriesBuilt && + hierarchyMode == lastHierarchyMode && + hierarchyRoot != null && + sel != null && + (sel == hierarchyRoot || sel.transform.IsChildOf(hierarchyRoot.transform))) + { + lastHierarchySelection = sel; + return; + } + + RefreshHierarchyEntries(); + lastHierarchySelection = sel; + lastHierarchyMode = hierarchyMode; + hierarchyEntriesBuilt = true; + } + + void OnEditorHierarchyChanged() + { + // Force the next RefreshHierarchyEntriesIfNeeded to rebuild. We + // don't rebuild here to avoid touching Selection off-frame and to + // keep the work inside the normal OnGUI pass (also avoids racing + // with active preview clones). + hierarchyEntriesBuilt = false; + } + + // Rebuild hierarchy entries from the current selection. Only active + // MeshRenderer descendants are collected; collision nodes are skipped. + // AO application writes only to the configured vertex channel — no + // other mesh data is touched. + void RefreshHierarchyEntries() + { + // Snapshot user include toggles so selection changes don't reset + // checkboxes the user already configured in the mesh list. + var prevInclude = new Dictionary(); + foreach (var e in hierarchyEntries) + if (e?.renderer != null) + prevInclude[e.renderer.GetInstanceID()] = e.include; + + hierarchyEntries.Clear(); + hierarchyRoot = Selection.activeGameObject; + if (hierarchyRoot == null) + return; + + var renderers = hierarchyRoot.GetComponentsInChildren(true); + foreach (var r in renderers) + { + if (r == null || !r.enabled || !r.gameObject.activeInHierarchy) + continue; + if (MeshHygieneUtility.IsCollisionNodeName(r.name)) + continue; + var mf = r.GetComponent(); + if (mf == null || mf.sharedMesh == null) + continue; + // Skip empty meshes — they can't participate in AO bake and + // would trip GPU compute buffer creation (count must be > 0). + if (mf.sharedMesh.vertexCount == 0) + continue; + + var entry = new MeshEntry + { + lodIndex = 0, + renderer = r, + meshFilter = mf, + originalMesh = mf.sharedMesh, + fbxMesh = mf.sharedMesh, + meshGroupKey = UvToolContext.ExtractGroupKey(r.name) + }; + if (prevInclude.TryGetValue(r.GetInstanceID(), out var prev)) + entry.include = prev; + hierarchyEntries.Add(entry); + } + // fbxOverwriteMap is resynced each paint inside DrawFbxOverwritePicker; + // no separate sync here. + } + + // Keep fbxOverwriteMap in sync with unique FBX asset paths referenced + // by INCLUDED hierarchyEntries. Excluded meshes drop out of the + // overwrite picker — only FBX files that AO actually applied to are + // candidates for re-save. New paths default to checked; paths that + // disappear (entry unchecked or removed) are dropped. + void SyncFbxOverwriteMap() + { + var current = new HashSet(); + foreach (var e in hierarchyEntries) + { + if (e == null || !e.include || e.fbxMesh == null) continue; + string p = AssetDatabase.GetAssetPath(e.fbxMesh); + if (string.IsNullOrEmpty(p)) continue; + if (!p.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase)) continue; + current.Add(p); + if (!fbxOverwriteMap.ContainsKey(p)) + fbxOverwriteMap[p] = true; + } + // Drop stale keys. + var stale = fbxOverwriteMap.Keys.Where(k => !current.Contains(k)).ToList(); + foreach (var k in stale) + fbxOverwriteMap.Remove(k); + } + + // Entries that drive bake / load / apply / preview. + IEnumerable ActiveEntries() + { + if (hierarchyMode) + return hierarchyEntries; + return ctx != null && ctx.MeshEntries != null + ? (IEnumerable)ctx.MeshEntries + : System.Array.Empty(); + } + + // Per-renderer include checkboxes (Hierarchy Mode only). Drives which + // meshes participate in Bake / Apply. Reuses MeshEntry.include which + // ExecuteBake / ApplyToMesh already filter by. + void DrawHierarchyMeshList() + { + int included = 0; + foreach (var e in hierarchyEntries) if (e.include) included++; + + hierarchyEntriesFoldout = EditorGUILayout.Foldout( + hierarchyEntriesFoldout, + $"Meshes ({included} / {hierarchyEntries.Count} included)", + true); + if (!hierarchyEntriesFoldout) return; + + EditorGUI.indentLevel++; + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("All", EditorStyles.miniButtonLeft)) + foreach (var e in hierarchyEntries) e.include = true; + if (GUILayout.Button("None", EditorStyles.miniButtonMid)) + foreach (var e in hierarchyEntries) e.include = false; + if (GUILayout.Button("Invert", EditorStyles.miniButtonRight)) + foreach (var e in hierarchyEntries) e.include = !e.include; + EditorGUILayout.EndHorizontal(); + + listVisibleRows = EditorGUILayout.IntSlider( + new GUIContent("Rows", "Number of rows visible before scrolling. Applies to Meshes and Overwrite FBX lists."), + listVisibleRows, 3, 30); + + float rowHeight = EditorGUIUtility.singleLineHeight + 4f; + float listHeight = Mathf.Min(hierarchyEntries.Count, listVisibleRows) * rowHeight + 6f; + hierarchyEntriesScroll = EditorGUILayout.BeginScrollView( + hierarchyEntriesScroll, + alwaysShowHorizontal: false, + alwaysShowVertical: false, + GUIStyle.none, + GUI.skin.verticalScrollbar, + GUI.skin.scrollView, + GUILayout.Height(listHeight)); + foreach (var e in hierarchyEntries) + { + if (e == null || e.renderer == null) continue; + string fbxName = e.fbxMesh != null + ? System.IO.Path.GetFileName(AssetDatabase.GetAssetPath(e.fbxMesh)) + : null; + string tooltip = string.IsNullOrEmpty(fbxName) + ? "Click to ping in Hierarchy" + : fbxName + "\nClick to ping in Hierarchy"; + + EditorGUILayout.BeginHorizontal(); + bool next = EditorGUILayout.Toggle(e.include, GUILayout.Width(22)); + if (next != e.include) e.include = next; + GUILayout.Space(4); + if (GUILayout.Button(new GUIContent(e.renderer.name, tooltip), + EditorStyles.label)) + { + // Select (not just ping) so the UV canvas can refresh onto + // this mesh. Root / mesh list survives via the + // descendant-of-root guard in RefreshHierarchyEntriesIfNeeded. + Selection.activeGameObject = e.renderer.gameObject; + EditorGUIUtility.PingObject(e.renderer.gameObject); + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + EditorGUI.indentLevel--; + } + void ClearResults() { bakedRawAO = null; @@ -174,21 +404,53 @@ void CancelGpuJob() // ── UI ── - public void OnDrawSidebar() + // Draws header toggle + hierarchy root info (or standalone/LOD help + // box). Returns false when nothing is selected that the tool can + // bake — caller should bail out. + bool DrawSelectionGate() { - EditorGUILayout.Space(8); - EditorGUILayout.LabelField("Vertex AO Baker", EditorStyles.boldLabel); - EditorGUILayout.Space(4); + hierarchyMode = EditorGUILayout.ToggleLeft( + new GUIContent("Hierarchy Mode", + "Bake AO across all active MeshRenderer descendants of the selected root as a single batch. " + + "Applies only the vertex AO channel — other mesh data is untouched."), + hierarchyMode); + + if (hierarchyMode) + { + // While preview is active, MeshFilters hold clone meshes — + // don't rebuild from them or Apply would target the clones. + // Preview is always restored before bake/load/apply, so the + // stale window is user-visible only (ok). + if (!previewActive) + RefreshHierarchyEntriesIfNeeded(); + if (hierarchyRoot == null || hierarchyEntries.Count == 0) + { + EditorGUILayout.HelpBox( + "Select a root GameObject that has active MeshRenderer descendants.", + MessageType.Info); + return false; + } + EditorGUILayout.LabelField( + $"Root: {hierarchyRoot.name} ({hierarchyEntries.Count} meshes)", + EditorStyles.miniLabel); + DrawHierarchyMeshList(); + return true; + } if (ctx == null || ctx.MeshEntries == null || ctx.MeshEntries.Count == 0) { EditorGUILayout.HelpBox( "Select a LODGroup or an individual MeshRenderer to bake vertex AO.", MessageType.Info); - return; + return false; } + return true; + } - // Bake mode selector + // Bake-mode selector + target channel + sample / res / radius / + // intensity + ground plane / backface / cosine + occluder mode. + void DrawBakeSettings() + { bool gpuAvailable = SystemInfo.supportsComputeShaders; if (gpuAvailable) { @@ -207,7 +469,6 @@ public void OnDrawSidebar() EditorGUILayout.Space(4); - // Target channel — two combo boxes EditorGUILayout.BeginHorizontal(); EditorGUILayout.PrefixLabel(new GUIContent("Target", "Channel to store AO values.")); channelType = EditorGUILayout.Popup(channelType, channelTypeNames); @@ -222,7 +483,6 @@ public void OnDrawSidebar() EditorGUILayout.Space(4); - // Bake settings sampleCountIndex = EditorGUILayout.Popup( new GUIContent("Sample Count", "Number of hemisphere directions to sample. Higher = smoother AO, slower bake."), sampleCountIndex, sampleLabels); @@ -239,7 +499,6 @@ public void OnDrawSidebar() new GUIContent("Intensity", "AO contrast. >1 = darker shadows, <1 = softer."), intensity, 0.5f, 3.0f); - // Ground plane & backface culling (not relevant for thickness) if (bakeTypeIndex == 0) { EditorGUILayout.Space(4); @@ -288,16 +547,243 @@ public void OnDrawSidebar() includeCollisionOccluders); } } + } + public void OnDrawSidebar() + { EditorGUILayout.Space(8); + EditorGUILayout.LabelField("Vertex Color Baking", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + DrawBakeKindSelector(); + EditorGUILayout.Space(4); + + if (!DrawSelectionGate()) return; + + if (bakeKind == BakeKind.AO) + { + DrawBakeSettings(); + + EditorGUILayout.Space(8); + + DrawBakeActionsRow(); + + if (bakedFinalAO == null || bakedFinalAO.Count == 0) return; + + DrawResults(); + DrawPostProcessing(); + DrawApplyFilters(); + DrawApplyAndExportRow(); + } + else // BakeKind.SolidColor + { + DrawSolidColorSettings(); + EditorGUILayout.Space(8); + DrawSolidBakeActionsRow(); + } + } + + void DrawBakeKindSelector() + { + EditorGUI.BeginChangeCheck(); + int newKind = GUILayout.Toolbar((int)bakeKind, bakeKindLabels, GUILayout.Height(22)); + if (EditorGUI.EndChangeCheck()) + { + bakeKind = (BakeKind)newKind; + if (bakeKind == BakeKind.SolidColor && previewActive) + RestorePreview(); + requestRepaint?.Invoke(); + } + } + + void DrawSolidColorSettings() + { + EditorGUILayout.LabelField("Solid Color Variants", EditorStyles.boldLabel); - // Bake button / progress + int? removeIndex = null; + for (int i = 0; i < variants.Count; i++) + { + var v = variants[i]; + EditorGUILayout.BeginHorizontal(); + v.color = EditorGUILayout.ColorField(GUIContent.none, v.color, true, true, false, GUILayout.Width(80)); + v.suffix = EditorGUILayout.TextField(v.suffix); + using (new EditorGUI.DisabledScope(variants.Count <= 1)) + { + if (GUILayout.Button("−", GUILayout.Width(24))) removeIndex = i; + } + EditorGUILayout.EndHorizontal(); + variants[i] = v; // struct write-back + } + if (removeIndex.HasValue) variants.RemoveAt(removeIndex.Value); + + if (GUILayout.Button("+ Add variant")) + { + variants.Add(new VariantExportPipeline.Variant + { + color = Color.white, + suffix = $"Variant{variants.Count + 1}" + }); + } + + string fbxPath = ctx?.SourceFbxPath; + string baseName = !string.IsNullOrEmpty(fbxPath) + ? System.IO.Path.GetFileNameWithoutExtension(fbxPath) + : "(no FBX)"; + string preview = string.Join(", ", variants.Select(v => $"{baseName}_{v.suffix}.fbx")); + EditorGUILayout.LabelField($"→ {preview}", EditorStyles.miniLabel); + EditorGUILayout.HelpBox( + "Skips collision meshes. Existing files are overwritten — use git to revert.", + MessageType.None); + } + + void DrawSolidBakeActionsRow() + { + var bgc = GUI.backgroundColor; + EditorGUILayout.BeginHorizontal(); + GUI.backgroundColor = new Color(.4f, .8f, .4f); + if (GUILayout.Button("Bake (preview)", GUILayout.Height(28))) + ExecuteBakeFirstVariant(); + GUI.backgroundColor = new Color(.4f, .7f, .95f); + if (GUILayout.Button("Bake & Export All", GUILayout.Height(28))) + ExecuteBakeAndExportVariants(); + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); + } + + void ExecuteBakeFirstVariant() + { + if (variants.Count == 0) + { + UvtLog.Warn("[Vertex Colors] No variants defined."); + return; + } + var entries = ActiveEntries() + .Where(e => e.include && e.renderer != null) + .ToList(); + if (entries.Count == 0) + { + UvtLog.Warn("[Vertex Colors] No meshes selected."); + return; + } + var first = variants[0]; + int painted = VariantExportPipeline.BakeSolidColorOnEntries(entries, first.color); + if (painted == 0) + { + UvtLog.Warn("[Vertex Colors] Nothing painted (all collision, excluded, or empty)."); + return; + } + string hex = ColorUtility.ToHtmlStringRGBA(first.color); + UvtLog.Info($"[Vertex Colors] Preview: '{first.suffix}' #{hex} on {painted} mesh(es)."); + requestRepaint?.Invoke(); + } + + void ExecuteBakeAndExportVariants() + { + if (variants.Count == 0) + { + UvtLog.Warn("[Vertex Colors] No variants defined."); + return; + } + var entries = ActiveEntries() + .Where(e => e.include && e.renderer != null) + .ToList(); + if (entries.Count == 0) + { + UvtLog.Warn("[Vertex Colors] No meshes selected."); + return; + } + + // In hierarchy mode ctx.SourceFbxPath can be empty (container + // selection) or stale (from a previous selection), so resolve the + // FBX from the included entries themselves. Reject selections that + // span multiple FBX files — variant export writes one output FBX. + string sourceFbxPath; + if (hierarchyMode) + { + var fbxPaths = new HashSet(System.StringComparer.OrdinalIgnoreCase); + foreach (var e in entries) + { + var srcMesh = e.fbxMesh ?? (e.meshFilter != null ? e.meshFilter.sharedMesh : null); + if (srcMesh == null) continue; + string p = AssetDatabase.GetAssetPath(srcMesh); + if (!string.IsNullOrEmpty(p) && p.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase)) + fbxPaths.Add(p); + } + if (fbxPaths.Count == 0) + { + UvtLog.Error("[Vertex Colors] No source FBX resolved from the included meshes."); + return; + } + if (fbxPaths.Count > 1) + { + UvtLog.Error("[Vertex Colors] Included meshes span multiple FBX files — " + + "variant export needs a single source FBX. Narrow the selection.\n " + + string.Join("\n ", fbxPaths)); + return; + } + sourceFbxPath = fbxPaths.First(); + } + else + { + sourceFbxPath = ctx?.SourceFbxPath; + } + if (string.IsNullOrEmpty(sourceFbxPath)) + { + UvtLog.Error("[Vertex Colors] No source FBX path resolved on the current selection."); + return; + } + + var hub = Resources.FindObjectsOfTypeAll(); + if (hub.Length == 0) + { + UvtLog.Error("[Vertex Colors] UvToolHub not found."); + return; + } + var fbxExporter = hub[0].FindTool(); + if (fbxExporter == null) + { + UvtLog.Error("[Vertex Colors] LightmapTransferTool not found."); + return; + } + + var sourcePrefab = ResolveSourcePrefab(); + if (sourcePrefab == null) + UvtLog.Warn("[Vertex Colors] No source prefab on selection — exporting FBX only."); + + var results = VariantExportPipeline.ExportVariants( + fbxExporter, sourceFbxPath, sourcePrefab, + entries, variants, + VariantExportPipeline.ConflictPolicy.Overwrite); + + int ok = results.Count(r => r.ok); + int fail = results.Count - ok; + UvtLog.Info($"[Vertex Colors] Variant export — {ok} ok, {fail} failed (of {variants.Count})."); + foreach (var r in results) + if (!r.ok) UvtLog.Error($"[Vertex Colors] '{r.suffix}': {r.error}"); + + requestRepaint?.Invoke(); + } + + GameObject ResolveSourcePrefab() + { + GameObject go = ctx?.LodGroup != null ? ctx.LodGroup.gameObject : null; + if (go == null && ctx?.MeshEntries != null && ctx.MeshEntries.Count > 0) + go = ctx.MeshEntries[0].renderer != null ? ctx.MeshEntries[0].renderer.gameObject : null; + if (go == null) return null; + var instanceRoot = PrefabUtility.GetNearestPrefabInstanceRoot(go); + if (instanceRoot == null) return null; + return PrefabUtility.GetCorrespondingObjectFromSource(instanceRoot) as GameObject; + } + + // Bake / Load / Rebake Selected buttons, or progress + cancel while + // a GPU job is running. + void DrawBakeActionsRow() + { bool isBaking = activeGpuJob != null && activeGpuJob.IsRunning; var bgc = GUI.backgroundColor; if (isBaking) { - // Inline progress bar + cancel var rect = EditorGUILayout.GetControlRect(false, 22); EditorGUI.ProgressBar(rect, activeGpuJob.Progress, activeGpuJob.StatusText); GUI.backgroundColor = new Color(.9f, .5f, .3f); @@ -307,163 +793,295 @@ public void OnDrawSidebar() UvtLog.Info("[Vertex AO] GPU bake cancelled."); } GUI.backgroundColor = bgc; + return; } - else + + EditorGUILayout.BeginHorizontal(); + GUI.backgroundColor = new Color(.4f, .8f, .4f); + if (GUILayout.Button("Bake Vertex AO", GUILayout.Height(28))) + ExecuteBake(); + GUI.backgroundColor = new Color(.6f, .75f, .9f); + if (GUILayout.Button("Load from Mesh", GUILayout.Height(28))) + LoadFromMesh(); + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); + + if (!hierarchyMode) return; + + var selectedEntry = FindSelectedHierarchyEntry(); + GUI.backgroundColor = new Color(.85f, .75f, .4f); + using (new EditorGUI.DisabledScope(selectedEntry == null)) { - EditorGUILayout.BeginHorizontal(); - GUI.backgroundColor = new Color(.4f, .8f, .4f); - if (GUILayout.Button("Bake Vertex AO", GUILayout.Height(28))) - ExecuteBake(); - GUI.backgroundColor = new Color(.6f, .75f, .9f); - if (GUILayout.Button("Load from Mesh", GUILayout.Height(28))) - LoadFromMesh(); - GUI.backgroundColor = bgc; - EditorGUILayout.EndHorizontal(); + string label = selectedEntry != null + ? $"Rebake Selected ({selectedEntry.renderer.name})" + : "Rebake Selected"; + if (GUILayout.Button(new GUIContent(label, + "Rebake AO for the currently selected hierarchy mesh using current UI settings. " + + "Other meshes' results are preserved."), GUILayout.Height(24))) + ExecuteRebakeSelected(selectedEntry); } + GUI.backgroundColor = bgc; + } + + // Readonly post-bake summary labels. + void DrawResults() + { + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("Results", EditorStyles.boldLabel); + EditorGUILayout.LabelField( + $" {bakedVertexCount:N0} vertices, {sampleCounts[sampleCountIndex]} samples, {bakeTimeSeconds:F1}s", + EditorStyles.miniLabel); + EditorGUILayout.LabelField( + $" Target: {TargetChannelName}", + EditorStyles.miniLabel); + EditorGUILayout.LabelField( + $" Bake meshes: {lastTargetMeshCount}, nearby occluders: {lastRenderableOccluderCount}, collision occluders: {lastColliderOccluderCount}", + EditorStyles.miniLabel); + if (lastUsedSidecarCollisionFallback) + EditorGUILayout.LabelField(" Collision source: sidecar fallback", EditorStyles.miniLabel); + } + + // Face-area / topo blur / 3D blur / brightness+contrast. All re-run + // ApplyBlur on change for live preview. + void DrawPostProcessing() + { + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Post-Processing", EditorStyles.boldLabel); + EditorGUI.BeginChangeCheck(); + + faceAreaStrength = EditorGUILayout.Slider( + new GUIContent("Face-Area Fix", "Blend raw AO with face-area corrected. Fixes black polygons with open surfaces."), + faceAreaStrength, 0f, 1f); - // Results - if (bakedFinalAO != null && bakedFinalAO.Count > 0) + EditorGUILayout.Space(2); + topoBlurIter = EditorGUILayout.IntSlider( + new GUIContent("Topo Blur", "Topology blur along mesh edges."), + topoBlurIter, 0, 10); + if (topoBlurIter > 0) { - EditorGUILayout.Space(8); - EditorGUILayout.LabelField("Results", EditorStyles.boldLabel); - EditorGUILayout.LabelField( - $" {bakedVertexCount:N0} vertices, {sampleCounts[sampleCountIndex]} samples, {bakeTimeSeconds:F1}s", - EditorStyles.miniLabel); - EditorGUILayout.LabelField( - $" Target: {TargetChannelName}", - EditorStyles.miniLabel); - EditorGUILayout.LabelField( - $" Bake meshes: {lastTargetMeshCount}, nearby occluders: {lastRenderableOccluderCount}, collision occluders: {lastColliderOccluderCount}", - EditorStyles.miniLabel); - if (lastUsedSidecarCollisionFallback) - EditorGUILayout.LabelField(" Collision source: sidecar fallback", EditorStyles.miniLabel); + topoBlurStr = EditorGUILayout.Slider( + new GUIContent(" Strength", "Blend factor per iteration."), + topoBlurStr, 0f, 1f); + topoCrossHardEdges = EditorGUILayout.Toggle( + new GUIContent(" Cross Hard Edges", "Blur across hard edges."), + topoCrossHardEdges); + topoCrossUvSeams = EditorGUILayout.Toggle( + new GUIContent(" Cross UV Seams", "Blur across UV shell boundaries."), + topoCrossUvSeams); + } - // Post-processing — all controls update preview in real-time - EditorGUILayout.Space(4); - EditorGUILayout.LabelField("Post-Processing", EditorStyles.boldLabel); - EditorGUI.BeginChangeCheck(); - - // Face-area correction blend - faceAreaStrength = EditorGUILayout.Slider( - new GUIContent("Face-Area Fix", "Blend raw AO with face-area corrected. Fixes black polygons with open surfaces."), - faceAreaStrength, 0f, 1f); - - // Topology blur - EditorGUILayout.Space(2); - topoBlurIter = EditorGUILayout.IntSlider( - new GUIContent("Topo Blur", "Topology blur along mesh edges."), - topoBlurIter, 0, 10); - if (topoBlurIter > 0) - { - topoBlurStr = EditorGUILayout.Slider( - new GUIContent(" Strength", "Blend factor per iteration."), - topoBlurStr, 0f, 1f); - topoCrossHardEdges = EditorGUILayout.Toggle( - new GUIContent(" Cross Hard Edges", "Blur across hard edges."), - topoCrossHardEdges); - topoCrossUvSeams = EditorGUILayout.Toggle( - new GUIContent(" Cross UV Seams", "Blur across UV shell boundaries."), - topoCrossUvSeams); - } + EditorGUILayout.Space(2); + spatialBlurIter = EditorGUILayout.IntSlider( + new GUIContent("3D Blur", "3D spatial blur — ignores topology, crosses all seams."), + spatialBlurIter, 0, 10); + if (spatialBlurIter > 0) + { + spatialBlurStr = EditorGUILayout.Slider( + new GUIContent(" Strength", "Blend factor per iteration."), + spatialBlurStr, 0f, 1f); + spatialBlurRadius = EditorGUILayout.Slider( + new GUIContent(" Radius", "3D search radius in world units."), + spatialBlurRadius, 0.01f, 2f); + } - // 3D Spatial blur - EditorGUILayout.Space(2); - spatialBlurIter = EditorGUILayout.IntSlider( - new GUIContent("3D Blur", "3D spatial blur — ignores topology, crosses all seams."), - spatialBlurIter, 0, 10); - if (spatialBlurIter > 0) - { - spatialBlurStr = EditorGUILayout.Slider( - new GUIContent(" Strength", "Blend factor per iteration."), - spatialBlurStr, 0f, 1f); - spatialBlurRadius = EditorGUILayout.Slider( - new GUIContent(" Radius", "3D search radius in world units."), - spatialBlurRadius, 0.01f, 2f); - } + EditorGUILayout.Space(2); + ppBrightness = EditorGUILayout.Slider( + new GUIContent("Brightness", "Shift AO values. + lighter, - darker."), + ppBrightness, -1f, 1f); + ppContrast = EditorGUILayout.Slider( + new GUIContent("Contrast", "AO contrast around 0.5. >1 = sharper, <1 = flatter."), + ppContrast, 0f, 3f); + if (EditorGUI.EndChangeCheck()) + ApplyBlur(); + } - // Levels - EditorGUILayout.Space(2); - ppBrightness = EditorGUILayout.Slider( - new GUIContent("Brightness", "Shift AO values. + lighter, - darker."), - ppBrightness, -1f, 1f); - ppContrast = EditorGUILayout.Slider( - new GUIContent("Contrast", "AO contrast around 0.5. >1 = sharper, <1 = flatter."), - ppContrast, 0f, 3f); - if (EditorGUI.EndChangeCheck()) - ApplyBlur(); + // Optional narrow-target apply: only selected renderer / submesh. + void DrawApplyFilters() + { + EditorGUILayout.Space(8); + applySelectedRendererOnly = EditorGUILayout.ToggleLeft( + new GUIContent("Apply only selected renderer", "Apply AO only to currently selected mesh renderer/skinned renderer."), + applySelectedRendererOnly); + if (!applySelectedRendererOnly) return; - EditorGUILayout.Space(8); + var selectedRenderer = Selection.activeGameObject != null + ? Selection.activeGameObject.GetComponentInParent() + : null; + int subCount = GetRendererSubmeshCount(selectedRenderer); + if (selectedRenderer == null || subCount <= 0) + { + EditorGUILayout.HelpBox("Select an object with MeshRenderer/SkinnedMeshRenderer for selective apply.", MessageType.Warning); + return; + } - applySelectedRendererOnly = EditorGUILayout.ToggleLeft( - new GUIContent("Apply only selected renderer", "Apply AO only to currently selected mesh renderer/skinned renderer."), - applySelectedRendererOnly); - if (applySelectedRendererOnly) + applySelectedSubmeshOnly = EditorGUILayout.ToggleLeft( + new GUIContent("Only selected submesh", "Apply AO only to vertices used by the chosen submesh. Other parts stay untouched."), + applySelectedSubmeshOnly); + if (applySelectedSubmeshOnly) + { + selectedSubmeshIndex = EditorGUILayout.IntSlider( + new GUIContent("Submesh Index"), + Mathf.Clamp(selectedSubmeshIndex, 0, subCount - 1), + 0, + subCount - 1); + } + } + + // Preview / Apply / Clear row + FBX overwrite (picker in hierarchy + // mode, single-button shortcut for LODGroup / standalone paths). + void DrawApplyAndExportRow() + { + var bgc = GUI.backgroundColor; + + EditorGUILayout.BeginHorizontal(); + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = previewActive ? new Color(.3f, .7f, 1f) : Color.white; + if (GUILayout.Button(previewActive ? "Preview ON" : "Preview", GUILayout.Height(24))) + { + if (previewActive) RestorePreview(); + else ActivatePreview(); + } + GUI.backgroundColor = prevBg; + + GUI.backgroundColor = new Color(.3f, .85f, .4f); + if (GUILayout.Button("Apply to Mesh", GUILayout.Height(24))) + ApplyToMesh(); + GUI.backgroundColor = new Color(.9f, .3f, .3f); + if (GUILayout.Button("Clear", GUILayout.Height(24))) + { + RestorePreview(); + ClearResults(); + requestRepaint?.Invoke(); + } + GUI.backgroundColor = bgc; + EditorGUILayout.EndHorizontal(); + + if (hierarchyMode) + { + DrawFbxOverwritePicker(); + return; + } + + GUI.backgroundColor = new Color(.4f, .7f, .95f); + if (GUILayout.Button("Overwrite FBX (Vertex Colors)", GUILayout.Height(22))) + { + var hub = Resources.FindObjectsOfTypeAll(); + if (hub.Length > 0) { - var selectedRenderer = Selection.activeGameObject != null - ? Selection.activeGameObject.GetComponentInParent() - : null; - int subCount = GetRendererSubmeshCount(selectedRenderer); - if (selectedRenderer == null || subCount <= 0) - { - EditorGUILayout.HelpBox("Select an object with MeshRenderer/SkinnedMeshRenderer for selective apply.", MessageType.Warning); - } + var transferTool = hub[0].FindTool(); + if (transferTool != null) + transferTool.ExportVertexColorsToFbx(); else - { - applySelectedSubmeshOnly = EditorGUILayout.ToggleLeft( - new GUIContent("Only selected submesh", "Apply AO only to vertices used by the chosen submesh. Other parts stay untouched."), - applySelectedSubmeshOnly); - if (applySelectedSubmeshOnly) - { - selectedSubmeshIndex = EditorGUILayout.IntSlider( - new GUIContent("Submesh Index"), - Mathf.Clamp(selectedSubmeshIndex, 0, subCount - 1), - 0, - subCount - 1); - } - } + UvtLog.Error("[Vertex AO] LightmapTransferTool not found."); } + } + GUI.backgroundColor = bgc; + } - // Preview / Apply / Clear — three buttons - EditorGUILayout.BeginHorizontal(); + // Per-FBX overwrite picker for Hierarchy Mode. Lists each unique FBX + // referenced by hierarchyEntries; only checked files get rewritten. + void DrawFbxOverwritePicker() + { + // Re-sync each paint so toggling Meshes checkboxes updates the + // FBX list immediately. Cheap — set ops over hierarchyEntries. + SyncFbxOverwriteMap(); - // Preview toggle button (highlighted when active) - var prevBg = GUI.backgroundColor; - GUI.backgroundColor = previewActive ? new Color(.3f, .7f, 1f) : Color.white; - if (GUILayout.Button(previewActive ? "Preview ON" : "Preview", GUILayout.Height(24))) - { - if (previewActive) RestorePreview(); - else ActivatePreview(); - } - GUI.backgroundColor = prevBg; + if (fbxOverwriteMap.Count == 0) + { + EditorGUILayout.HelpBox( + "No included FBX-backed meshes. Tick at least one row in the Meshes list above.", + MessageType.Info); + return; + } - GUI.backgroundColor = new Color(.3f, .85f, .4f); - if (GUILayout.Button("Apply to Mesh", GUILayout.Height(24))) - ApplyToMesh(); - GUI.backgroundColor = new Color(.9f, .3f, .3f); - if (GUILayout.Button("Clear", GUILayout.Height(24))) + int checkedCount = 0; + foreach (var v in fbxOverwriteMap.Values) if (v) checkedCount++; + + fbxOverwriteFoldout = EditorGUILayout.Foldout( + fbxOverwriteFoldout, + $"Overwrite FBX ({checkedCount} / {fbxOverwriteMap.Count} selected)", + true); + if (!fbxOverwriteFoldout) return; + + EditorGUI.indentLevel++; + var paths = fbxOverwriteMap.Keys.OrderBy(p => p).ToList(); + + float rowHeight = EditorGUIUtility.singleLineHeight + 4f; + float listHeight = Mathf.Min(paths.Count, listVisibleRows) * rowHeight + 6f; + fbxOverwriteScroll = EditorGUILayout.BeginScrollView( + fbxOverwriteScroll, + alwaysShowHorizontal: false, + alwaysShowVertical: false, + GUIStyle.none, + GUI.skin.verticalScrollbar, + GUI.skin.scrollView, + GUILayout.Height(listHeight)); + + foreach (var path in paths) + { + string label = System.IO.Path.GetFileName(path); + bool cur = fbxOverwriteMap[path]; + EditorGUILayout.BeginHorizontal(); + bool next = EditorGUILayout.Toggle(cur, GUILayout.Width(22)); + if (next != cur) fbxOverwriteMap[path] = next; + GUILayout.Space(4); + if (GUILayout.Button(new GUIContent(label, path + "\nClick to ping in Project"), + EditorStyles.label)) { - RestorePreview(); - ClearResults(); - requestRepaint?.Invoke(); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) EditorGUIUtility.PingObject(asset); } - GUI.backgroundColor = bgc; EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + + var bgc = GUI.backgroundColor; + GUI.backgroundColor = checkedCount > 0 ? new Color(.4f, .7f, .95f) : Color.white; + using (new EditorGUI.DisabledScope(checkedCount == 0)) + { + if (GUILayout.Button($"Overwrite Selected FBX ({checkedCount})", GUILayout.Height(22))) + OverwriteSelectedFbx(); + } + GUI.backgroundColor = bgc; + EditorGUI.indentLevel--; + } + + void OverwriteSelectedFbx() + { + var selected = fbxOverwriteMap + .Where(kv => kv.Value) + .Select(kv => kv.Key) + .ToList(); + if (selected.Count == 0) return; + + string list = string.Join("\n", selected.Select(p => " • " + System.IO.Path.GetFileName(p))); + if (!EditorUtility.DisplayDialog( + "Overwrite Selected FBX", + $"Overwrite {selected.Count} FBX file(s)?\n\n{list}\n\nOnly vertex colors are updated. UV2 and topology stay unchanged.", + "Overwrite", "Cancel")) + return; + + var hub = Resources.FindObjectsOfTypeAll(); + if (hub.Length == 0) return; + var transferTool = hub[0].FindTool(); + if (transferTool == null) + { + UvtLog.Error("[Vertex AO] LightmapTransferTool not found."); + return; + } - // Export vertex colors to FBX (after Apply) - GUI.backgroundColor = new Color(.4f, .7f, .95f); - if (GUILayout.Button("Overwrite FBX (Vertex Colors)", GUILayout.Height(22))) + foreach (var path in selected) + { + var entriesForPath = hierarchyEntries + .Where(e => e.fbxMesh != null && + string.Equals(AssetDatabase.GetAssetPath(e.fbxMesh), path, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (entriesForPath.Count == 0) { - var hub = Resources.FindObjectsOfTypeAll(); - if (hub.Length > 0) - { - var transferTool = hub[0].FindTool(); - if (transferTool != null) - transferTool.ExportVertexColorsToFbx(); - else - UvtLog.Error("[Vertex AO] LightmapTransferTool not found."); - } + UvtLog.Warn($"[Vertex AO] No hierarchy entries map to '{path}', skipping."); + continue; } - GUI.backgroundColor = bgc; + transferTool.ExportVertexColorsToFbx(path, entriesForPath); } } @@ -474,7 +1092,12 @@ void ExecuteBake() CancelGpuJob(); RestorePreview(); - var entries = ctx.MeshEntries + // hierarchyEntries is kept fresh by OnDrawSidebar's + // RefreshHierarchyEntriesIfNeeded (runs every IMGUI pass when + // preview is off). Re-refreshing here would wipe user include + // toggles the snapshot restore in RefreshHierarchyEntries tries + // to preserve, and is redundant inside the same OnGUI pass. + var entries = ActiveEntries() .Where(e => e.include && e.renderer != null) .ToList(); @@ -484,27 +1107,50 @@ void ExecuteBake() return; } - var settings = new VertexAOSettings - { - sampleCount = sampleCounts[sampleCountIndex], - depthResolution = resolutions[resolutionIndex], - maxRadius = maxRadius, - intensity = intensity, - groundPlane = groundPlane, - groundOffset = groundOffset, - backfaceCulling = backfaceCulling, - cosineWeighted = cosineWeighted, - binaryHit = binaryHit, - useGPU = bakeMode == 0, - bakeType = (AOBakeType)bakeTypeIndex, - occluderMode = bakeTypeIndex == 0 - ? (VertexAOOccluderMode)occluderModeIndex - : VertexAOOccluderMode.SelfOnly, - occluderRadiusMultiplier = Mathf.Max(occluderRadiusMultiplier, 0.01f), - includeCollisionOccluders = bakeTypeIndex == 0 && includeCollisionOccluders - }; + var settings = BuildSettingsFromUI(); + + // Color-space sanity: AO is computed + stored in LINEAR [0,1]. + // In a Linear-space project the preview shader renders linearly + // and Unity does the final linear→sRGB for display; vertex-color + // writes are raw byte-remapped (no gamma conversion). In a Gamma + // project the byte value is displayed as-is. Log once so the + // user can verify this matches their shader's expectations. + UvtLog.Info($"[Vertex AO] Project color space: {PlayerSettings.colorSpace}. " + + $"AO values are linear in [0,1]; vertex color / UV writes store them as-is."); var batches = BuildLodBatches(entries, settings); + RunBatches(batches, settings, entries, seedFromExisting: false); + } + + VertexAOSettings BuildSettingsFromUI() => new VertexAOSettings + { + sampleCount = sampleCounts[sampleCountIndex], + depthResolution = resolutions[resolutionIndex], + maxRadius = maxRadius, + intensity = intensity, + groundPlane = groundPlane, + groundOffset = groundOffset, + backfaceCulling = backfaceCulling, + cosineWeighted = cosineWeighted, + binaryHit = binaryHit, + useGPU = bakeMode == 0, + bakeType = (AOBakeType)bakeTypeIndex, + occluderMode = bakeTypeIndex == 0 + ? (VertexAOOccluderMode)occluderModeIndex + : VertexAOOccluderMode.SelfOnly, + occluderRadiusMultiplier = Mathf.Max(occluderRadiusMultiplier, 0.01f), + includeCollisionOccluders = bakeTypeIndex == 0 && includeCollisionOccluders + }; + + // Kick off batches via GPU (async) or CPU (sync). When seedFromExisting + // is true, pre-populate pending dicts from current bakedRaw/FaceArea so + // a partial rebake merges into existing results instead of replacing. + void RunBatches( + List batches, + VertexAOSettings settings, + List entries, + bool seedFromExisting) + { if (batches.Count == 0) { UvtLog.Warn("[Vertex AO] No valid meshes to bake."); @@ -514,12 +1160,19 @@ void ExecuteBake() bakeStopwatch = Stopwatch.StartNew(); + var seedRaw = seedFromExisting && bakedRawAO != null + ? new Dictionary(bakedRawAO) + : new Dictionary(); + var seedFace = seedFromExisting && bakedFaceAreaAO != null + ? new Dictionary(bakedFaceAreaAO) + : new Dictionary(); + if (settings.useGPU && SystemInfo.supportsComputeShaders) { pendingLodBatches = batches; pendingLodBatchIndex = 0; - pendingRawAO = new Dictionary(); - pendingFaceAreaAO = new Dictionary(); + pendingRawAO = seedRaw; + pendingFaceAreaAO = seedFace; pendingBakeEntries = entries; pendingBakeSettings = settings; @@ -531,8 +1184,64 @@ void ExecuteBake() { if (settings.useGPU && !SystemInfo.supportsComputeShaders) UvtLog.Warn("[Vertex AO] Compute shaders not supported. Falling back to CPU."); - ExecuteBakeCPU(batches, settings, entries); + ExecuteBakeCPU(batches, settings, entries, seedRaw, seedFace); + } + } + + MeshEntry FindSelectedHierarchyEntry() + { + if (!hierarchyMode) return null; + var go = Selection.activeGameObject; + if (go == null) return null; + var mr = go.GetComponentInParent(); + if (mr == null) return null; + foreach (var e in hierarchyEntries) + if (e.renderer == mr) return e; + return null; + } + + void ExecuteRebakeSelected(MeshEntry selEntry) + { + if (selEntry == null || selEntry.renderer == null) + { + UvtLog.Warn("[Vertex AO] Select a hierarchy mesh in the scene to rebake."); + return; + } + + Mesh selectedMesh = selEntry.originalMesh ?? selEntry.fbxMesh; + if (selectedMesh == null) return; + + CancelGpuJob(); + RestorePreview(); + + var settings = BuildSettingsFromUI(); + + // Single-target batch; other hierarchy targets become occluders so + // the context matches a full bake. CollectAdditionalOccluders then + // layers in nearby / collision geometry per user's occluder mode + // (skipping renderers we pre-added here to avoid double-add). + var batch = new LodBakeBatch { lodIndex = 0 }; + batch.targetEntries.Add(selEntry); + batch.targetMeshes.Add((selectedMesh, selEntry.renderer.transform.localToWorldMatrix)); + batch.preAddedOccluderRendererIds = new HashSet(); + + foreach (var e in hierarchyEntries) + { + if (e == selEntry || !e.include || e.renderer == null) continue; + Mesh m = e.originalMesh ?? e.fbxMesh; + if (m == null) continue; + batch.occluderMeshes.Add((m, e.renderer.transform.localToWorldMatrix)); + batch.renderableOccluderCount++; + batch.preAddedOccluderRendererIds.Add(e.renderer.GetInstanceID()); } + + CollectAdditionalOccluders(batch, hierarchyEntries, settings); + + RunBatches( + new List { batch }, + settings, + new List { selEntry }, + seedFromExisting: true); } List BuildLodBatches(List entries, VertexAOSettings settings) @@ -588,8 +1297,19 @@ void CollectAdditionalOccluders( if (candidateRenderers == null || candidateRenderers.Length == 0) return; - var targetAnchors = ComputeTargetAnchors(batch.targetEntries, out Bounds targetBounds); - float effectiveRadius = Mathf.Max(targetBounds.extents.magnitude * settings.occluderRadiusMultiplier, 0.01f); + var targetAnchors = ComputeTargetAnchors(batch.targetEntries, out Bounds targetBounds, out var perTargetBounds); + // Base the radius on the largest single target extent (not the + // encapsulated extent), so spread-out target sets don't inflate + // the radius into absurd values that pull in irrelevant geometry. + float largestTargetExtent = 0f; + for (int i = 0; i < perTargetBounds.Count; i++) + { + float e = perTargetBounds[i].extents.magnitude; + if (e > largestTargetExtent) largestTargetExtent = e; + } + if (largestTargetExtent <= 0f) + largestTargetExtent = targetBounds.extents.magnitude; + float effectiveRadius = Mathf.Max(largestTargetExtent * settings.occluderRadiusMultiplier, 0.01f); var targetRendererIds = new HashSet( batch.targetEntries @@ -607,6 +1327,27 @@ void CollectAdditionalOccluders( } } + // When colliders are enabled as occluders, a collider mesh whose + // .name ends with "_COL" REPLACES the renderer whose mesh name + // equals the collider base (the collider name minus "_COL"). The + // suffix lives on the mesh asset name — the GameObject node may + // be named anything. Pre-walk collision mesh candidates to + // collect base keys; renderers whose mesh name matches are + // skipped below. + HashSet collCoveredKeys = null; + if (settings.includeCollisionOccluders) + { + collCoveredKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var collCandidates = new List<(GameObject go, Mesh mesh)>(); + CollectCollisionMeshCandidates(root.transform, collCandidates); + foreach (var c in collCandidates) + { + string baseName = StripColSuffix(c.mesh.name); + if (!string.IsNullOrEmpty(baseName)) + collCoveredKeys.Add(baseName); + } + } + var seenRenderers = new HashSet(); foreach (var renderer in candidateRenderers) { @@ -620,11 +1361,17 @@ void CollectAdditionalOccluders( continue; if (alternateLodRendererIds.Contains(rendererId)) continue; + if (batch.preAddedOccluderRendererIds != null && + batch.preAddedOccluderRendererIds.Contains(rendererId)) + continue; if (MeshHygieneUtility.IsCollisionNodeName(renderer.name)) continue; if (!TryGetRendererOccluderData(renderer, out var mesh, out var matrix, out var bounds, out var anchor)) continue; - if (!IsWithinOccluderRange(targetBounds, targetAnchors, bounds, anchor, effectiveRadius)) + if (collCoveredKeys != null && collCoveredKeys.Count > 0 && + mesh != null && collCoveredKeys.Contains(mesh.name)) + continue; // replaced by matching "_COL" collider below + if (!IsWithinOccluderRange(targetBounds, perTargetBounds, targetAnchors, bounds, anchor, effectiveRadius)) continue; batch.occluderMeshes.Add((mesh, matrix)); @@ -634,15 +1381,24 @@ void CollectAdditionalOccluders( if (!settings.includeCollisionOccluders) return; - int liveColliderCount = CollectLiveCollisionOccluders(batch, root, targetBounds, targetAnchors, effectiveRadius); + int liveColliderCount = CollectLiveCollisionOccluders(batch, root, targetBounds, perTargetBounds, targetAnchors, effectiveRadius); if (liveColliderCount > 0 || string.IsNullOrEmpty(ctx?.SourceFbxPath)) return; - CollectSidecarCollisionOccluders(batch, candidateRenderers, targetBounds, targetAnchors, effectiveRadius); + CollectSidecarCollisionOccluders(batch, candidateRenderers, targetBounds, perTargetBounds, targetAnchors, effectiveRadius); } GameObject ResolveOccluderRoot(List targetEntries) { + // Hierarchy mode searches across the whole scene root (not only + // under the user-selected subtree) so a single selected mesh can + // still pick up nearby occluders / _COL* siblings from elsewhere + // in the level. Targets themselves are filtered out via + // targetRendererIds in CollectAdditionalOccluders; range test + // keeps things local. + if (hierarchyMode && hierarchyRoot != null) + return hierarchyRoot.transform.root.gameObject; + if (ctx?.LodGroup != null) return ctx.LodGroup.gameObject; @@ -658,33 +1414,92 @@ int CollectLiveCollisionOccluders( LodBakeBatch batch, GameObject root, Bounds targetBounds, + List perTargetBounds, List targetAnchors, float effectiveRadius) { int added = 0; - var seen = new HashSet(); - foreach (var go in MeshHygieneUtility.FindCollisionObjects(root.transform)) + var candidates = new List<(GameObject go, Mesh mesh)>(); + CollectCollisionMeshCandidates(root.transform, candidates); + // CollectCollisionMeshCandidates already dedups per (GO, mesh); + // don't dedup by mesh ID alone — instanced colliders (same + // _COL asset at different world positions) must each contribute + // a distinct occluder in the BVH. + foreach (var c in candidates) { - if (go == null || !go.activeInHierarchy) - continue; - if (!seen.Add(go.GetInstanceID())) - continue; - if (!TryGetCollisionMesh(go, out var mesh, out var matrix, out var bounds, out var anchor)) - continue; - if (!IsWithinOccluderRange(targetBounds, targetAnchors, bounds, anchor, effectiveRadius)) + if (c.go == null || !c.go.activeInHierarchy) continue; + + var matrix = c.go.transform.localToWorldMatrix; + var bounds = TransformBounds(c.mesh.bounds, matrix); + var anchor = GetMeshWorldAnchor(c.mesh, matrix); + if (!IsWithinOccluderRange(targetBounds, perTargetBounds, targetAnchors, bounds, anchor, effectiveRadius)) continue; - batch.occluderMeshes.Add((mesh, matrix)); + batch.occluderMeshes.Add((c.mesh, matrix)); batch.colliderOccluderCount++; added++; } return added; } + // Collision detection is by MESH-NAME suffix "_COL", not by GameObject + // node name. Walks both MeshCollider.sharedMesh and MeshFilter. + // sharedMesh; dedups per (GO, mesh) so a GO with both MC+MF pointing + // at the same asset isn't added twice. Strict trailing-"_COL" rule + // mirrors the renderer-skip key (mesh name minus "_COL"). + static void CollectCollisionMeshCandidates( + Transform root, + List<(GameObject go, Mesh mesh)> result) + { + if (root == null) return; + + foreach (var mc in root.GetComponentsInChildren(true)) + { + if (mc == null || mc.sharedMesh == null) continue; + if (!EndsWithColSuffix(mc.sharedMesh.name)) continue; + result.Add((mc.gameObject, mc.sharedMesh)); + } + + foreach (var mf in root.GetComponentsInChildren(true)) + { + if (mf == null || mf.sharedMesh == null) continue; + if (!EndsWithColSuffix(mf.sharedMesh.name)) continue; + + bool dup = false; + for (int i = 0; i < result.Count; i++) + { + if (result[i].go == mf.gameObject && result[i].mesh == mf.sharedMesh) + { dup = true; break; } + } + if (!dup) result.Add((mf.gameObject, mf.sharedMesh)); + } + } + + // Collider mesh-name convention: "_COL" OR "_COL_Hull{digits}" + // (convex hull decomposition suffix). Both forms map to the same + // base name via StripColSuffix. + static bool EndsWithColSuffix(string name) + => StripColSuffix(name) != name; + + static string StripColSuffix(string name) + { + if (string.IsNullOrEmpty(name)) return name; + if (name.EndsWith("_COL", StringComparison.OrdinalIgnoreCase)) + return name.Substring(0, name.Length - 4); + int hullIdx = name.LastIndexOf("_COL_Hull", StringComparison.OrdinalIgnoreCase); + if (hullIdx < 0) return name; + int digitStart = hullIdx + 9; // "_COL_Hull".Length + if (digitStart >= name.Length) return name; + for (int i = digitStart; i < name.Length; i++) + if (!char.IsDigit(name[i])) return name; + return name.Substring(0, hullIdx); + } + void CollectSidecarCollisionOccluders( LodBakeBatch batch, Renderer[] candidateRenderers, Bounds targetBounds, + List perTargetBounds, List targetAnchors, float effectiveRadius) { @@ -723,7 +1538,7 @@ void CollectSidecarCollisionOccluders( var bounds = TransformBounds(mesh.bounds, matrix); var anchor = GetMeshWorldAnchor(mesh, matrix); - if (!IsWithinOccluderRange(targetBounds, targetAnchors, bounds, anchor, effectiveRadius)) + if (!IsWithinOccluderRange(targetBounds, perTargetBounds, targetAnchors, bounds, anchor, effectiveRadius)) continue; batch.occluderMeshes.Add((mesh, matrix)); @@ -823,40 +1638,15 @@ static bool TryGetRendererOccluderData( return true; } - static bool TryGetCollisionMesh(GameObject go, out Mesh mesh, out Matrix4x4 matrix, out Bounds bounds, out Vector3 anchor) - { - mesh = null; - matrix = Matrix4x4.identity; - bounds = default; - anchor = Vector3.zero; - if (go == null) - return false; - - var meshCollider = go.GetComponent(); - if (meshCollider != null && meshCollider.sharedMesh != null) - mesh = meshCollider.sharedMesh; - - if (mesh == null) - { - var meshFilter = go.GetComponent(); - if (meshFilter != null && meshFilter.sharedMesh != null) - mesh = meshFilter.sharedMesh; - } - - if (mesh == null) - return false; - - matrix = go.transform.localToWorldMatrix; - bounds = TransformBounds(mesh.bounds, matrix); - anchor = GetMeshWorldAnchor(mesh, matrix); - return true; - } - - static List ComputeTargetAnchors(List entries, out Bounds bounds) + static List ComputeTargetAnchors( + List entries, + out Bounds bounds, + out List perTargetBounds) { bounds = default; bool hasBounds = false; var anchors = new List(); + perTargetBounds = new List(); foreach (var entry in entries) { if (entry?.renderer == null) @@ -869,6 +1659,7 @@ static List ComputeTargetAnchors(List entries, out Bounds bo var matrix = entry.renderer.transform.localToWorldMatrix; var worldBounds = GetRendererWorldBounds(entry.renderer, mesh, matrix); anchors.Add(GetRendererWorldAnchor(entry.renderer, mesh, matrix)); + perTargetBounds.Add(worldBounds); if (!hasBounds) { @@ -929,22 +1720,40 @@ static Bounds TransformBounds(Bounds localBounds, Matrix4x4 matrix) static bool IsWithinOccluderRange( Bounds targetBounds, + List perTargetBounds, List targetAnchors, Bounds candidateBounds, Vector3 candidateAnchor, float radius) { + // Combined bbox vs candidate bbox — fast accept for candidates + // close to the overall target region. if (BoundsDistance(targetBounds, candidateBounds) <= radius) return true; - if (targetAnchors == null || targetAnchors.Count == 0) - return false; + // Per-target bbox vs candidate bbox — catches candidates that are + // near an individual target but far from the combined centroid + // (common in spread-out hierarchies). Uses bbox-to-bbox distance + // so a large candidate next to a small target is still captured. + if (perTargetBounds != null) + { + for (int i = 0; i < perTargetBounds.Count; i++) + { + if (BoundsDistance(perTargetBounds[i], candidateBounds) <= radius) + return true; + } + } - float sqrRadius = radius * radius; - for (int i = 0; i < targetAnchors.Count; i++) + // Pivot-to-pivot fallback (cheap, catches edge cases where bounds + // are tiny / degenerate). + if (targetAnchors != null && targetAnchors.Count > 0) { - if ((targetAnchors[i] - candidateAnchor).sqrMagnitude <= sqrRadius) - return true; + float sqrRadius = radius * radius; + for (int i = 0; i < targetAnchors.Count; i++) + { + if ((targetAnchors[i] - candidateAnchor).sqrMagnitude <= sqrRadius) + return true; + } } return false; @@ -1111,10 +1920,12 @@ void RepaintDuringBake() void ExecuteBakeCPU( List batches, VertexAOSettings settings, - List entries) + List entries, + Dictionary seedRaw = null, + Dictionary seedFace = null) { - bakedRawAO = new Dictionary(); - bakedFaceAreaAO = new Dictionary(); + bakedRawAO = seedRaw ?? new Dictionary(); + bakedFaceAreaAO = seedFace ?? new Dictionary(); foreach (var batch in batches) { @@ -1167,7 +1978,9 @@ void LoadFromMesh() { RestorePreview(); - var entries = ctx.MeshEntries + // OnDrawSidebar keeps hierarchyEntries fresh; no explicit refresh + // here (would wipe user include toggles). + var entries = ActiveEntries() .Where(e => e.include && e.renderer != null) .ToList(); @@ -1246,7 +2059,7 @@ static float[] ReadFromChannel(Mesh mesh, AOTargetChannel channel) if (uvs.Count != vertCount) return null; var ao = new float[vertCount]; for (int i = 0; i < vertCount; i++) - ao[i] = comp == 0 ? uvs[i].x : uvs[i].y; + ao[i] = Mathf.Clamp01(comp == 0 ? uvs[i].x : uvs[i].y); return ao; } } @@ -1356,21 +2169,17 @@ void ApplyToMesh() // repacked/transferred variants. Mirror AO into all mesh variants // of the same entry so overwrite export persists the selected channel. var targetMeshes = new List { seedMesh }; - MeshEntry owner = null; - if (ctx?.MeshEntries != null) + MeshEntry owner = ActiveEntries().FirstOrDefault(e => + e.originalMesh == seedMesh || + e.fbxMesh == seedMesh || + e.repackedMesh == seedMesh || + e.transferredMesh == seedMesh); + if (owner != null) { - owner = ctx.MeshEntries.FirstOrDefault(e => - e.originalMesh == seedMesh || - e.fbxMesh == seedMesh || - e.repackedMesh == seedMesh || - e.transferredMesh == seedMesh); - if (owner != null) - { - if (owner.originalMesh != null) targetMeshes.Add(owner.originalMesh); - if (owner.repackedMesh != null) targetMeshes.Add(owner.repackedMesh); - if (owner.transferredMesh != null) targetMeshes.Add(owner.transferredMesh); - if (owner.fbxMesh != null) targetMeshes.Add(owner.fbxMesh); - } + if (owner.originalMesh != null) targetMeshes.Add(owner.originalMesh); + if (owner.repackedMesh != null) targetMeshes.Add(owner.repackedMesh); + if (owner.transferredMesh != null) targetMeshes.Add(owner.transferredMesh); + if (owner.fbxMesh != null) targetMeshes.Add(owner.fbxMesh); } if (applySelectedRendererOnly) @@ -1445,7 +2254,7 @@ void ActivatePreview() previewMaterial.SetInt("_ZWrite", 1); } - foreach (var e in ctx.MeshEntries) + foreach (var e in ActiveEntries()) { if (!e.include || e.renderer == null) continue; var mf = e.meshFilter; @@ -1500,26 +2309,6 @@ internal void RestorePreview() SceneView.RepaintAll(); } - void UpdatePreviewColors() - { - if (!previewActive || bakedFinalAO == null) return; - // Update preview clone colors without re-creating everything - foreach (var (mf, originalMesh, _) in previewBackups) - { - if (mf == null || mf.sharedMesh == null) continue; - if (!bakedFinalAO.TryGetValue(originalMesh, out var ao)) continue; - var clone = mf.sharedMesh; - var colors = new Color32[clone.vertexCount]; - for (int i = 0; i < colors.Length && i < ao.Length; i++) - { - byte v = (byte)(Mathf.Clamp01(ao[i]) * 255f); - colors[i] = new Color32(v, v, v, 255); - } - clone.colors32 = colors; - } - SceneView.RepaintAll(); - } - // ── Unused interface ── public void OnDrawToolbarExtra() { } diff --git a/Editor/Tools/VertexAOTool.cs.meta b/Editor/Tools/VertexColorBakingTool.cs.meta similarity index 100% rename from Editor/Tools/VertexAOTool.cs.meta rename to Editor/Tools/VertexColorBakingTool.cs.meta diff --git a/Editor/Uv0Analyzer.cs b/Editor/Uv0Analyzer.cs index 8425475d..d77b204d 100644 --- a/Editor/Uv0Analyzer.cs +++ b/Editor/Uv0Analyzer.cs @@ -140,7 +140,7 @@ public static Uv0Report Analyze(Mesh mesh) /// /// Returns the set of vertex indices that participate in false seams - /// (duplicate position+UV0+normal). Used by Model Builder problem preview. + /// (duplicate position+UV0+normal). Used by Prefab Builder problem preview. /// Returns null if no false seams found or mesh has no UV0. /// internal static HashSet GetFalseSeamVertices(Mesh mesh) diff --git a/Editor/VertexAOBaker.Gpu.cs b/Editor/VertexAOBaker.Gpu.cs index ceeaa2f7..91bf8ad5 100644 --- a/Editor/VertexAOBaker.Gpu.cs +++ b/Editor/VertexAOBaker.Gpu.cs @@ -140,6 +140,13 @@ void Prepare( List<(Mesh mesh, Matrix4x4 transform)> targets, List<(Mesh mesh, Matrix4x4 transform)> occluders) { + // Filter out zero-vertex meshes — ComputeBuffer construction + // rejects count==0, so one empty target would poison the batch. + if (targets != null) + targets = targets.FindAll(t => t.mesh != null && t.mesh.vertexCount > 0); + if (occluders != null) + occluders = occluders.FindAll(t => t.mesh != null && t.mesh.vertexCount > 0); + if (targets == null || targets.Count == 0) throw new Exception("GPU bake requires at least one target mesh."); diff --git a/Editor/VertexAOBaker.cs b/Editor/VertexAOBaker.cs index dff423dc..9abb3646 100644 --- a/Editor/VertexAOBaker.cs +++ b/Editor/VertexAOBaker.cs @@ -133,6 +133,15 @@ public static Dictionary ApplyFaceAreaCorrection( return result; } + /// + /// Store baked AO into the selected channel. Both paths clamp to + /// [0,1]: the vertex-color write remaps to byte [0..255], the UV + /// write keeps the raw float. AO values are treated as LINEAR — no + /// gamma encoding is applied. Shaders reading the channel must + /// interpret it in linear space (URP / Built-in vertex colors are + /// already handled linearly when the project is in Linear color + /// space). + /// public static void WriteToChannel(Mesh mesh, float[] aoValues, AOTargetChannel channel) { if (mesh == null || aoValues == null || aoValues.Length != mesh.vertexCount) return; @@ -178,9 +187,14 @@ public static void WriteToChannel(Mesh mesh, float[] aoValues, AOTargetChannel c } for (int i = 0; i < aoValues.Length; i++) { + // Clamp to [0,1] — matches the vertex-color path and keeps + // downstream shaders well-defined. Upstream math (area + // correction, blur) can drift slightly out of range due + // to float error / user intensity tweaks. + float v = Mathf.Clamp01(aoValues[i]); var uv = uvs[i]; - if (comp == 0) uv.x = aoValues[i]; - else uv.y = aoValues[i]; + if (comp == 0) uv.x = v; + else uv.y = v; uvs[i] = uv; } mesh.SetUVs(uvIdx, uvs); diff --git a/Plugins/macOS/libxatlas-unity.dylib b/Plugins/macOS/libxatlas-unity.dylib index a46a8960..52e7ebe7 100644 Binary files a/Plugins/macOS/libxatlas-unity.dylib and b/Plugins/macOS/libxatlas-unity.dylib differ diff --git a/Plugins/x86_64/xatlas-unity.dll b/Plugins/x86_64/xatlas-unity.dll index 340df2e8..4b39997a 100755 Binary files a/Plugins/x86_64/xatlas-unity.dll and b/Plugins/x86_64/xatlas-unity.dll differ diff --git a/README.md b/README.md index eeaeeece..2ac53a72 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Mesh Lab provides six integrated tools accessible via **Tools → Mesh Lab**: | **UV0 Optimize** | UV0 analysis and optimization | | **LOD Gen** | Generate LOD meshes via meshoptimizer simplification with UV2 preservation | | **Collision** | Generate collision meshes — simplified (non-convex) or V-HACD convex decomposition | -| **Vertex AO** | GPU-accelerated vertex ambient occlusion baking via hemisphere depth sampling | +| **Vertex Color Baking** | Vertex AO (GPU hemisphere depth sampling) and Solid Color batch export with FBX + Prefab variants | ## UV2 Transfer @@ -80,7 +80,11 @@ When no LODGroup exists, the Setup tab and LOD Gen tab detect LOD siblings autom After creation, use LOD Gen to generate lower LODs and the naming (`_LOD1`, `_LOD2`) is handled automatically. -## Vertex AO Baking +## Vertex Color Baking + +A single tab with two bake modes selected by toolbar at the top: + +### AO mode GPU-accelerated per-vertex ambient occlusion via hemisphere depth sampling: @@ -90,13 +94,23 @@ GPU-accelerated per-vertex ambient occlusion via hemisphere depth sampling: * CPU fallback for platforms without compute shader support * Results written to vertex colors or UV channels +### Solid Color mode + +Batch export of color variants from one source FBX/prefab: + +* Edit a list of `(Color, suffix)` rows; `+ Add variant` to append, `−` to remove +* `Bake (preview)` paints `variants[0]` onto working meshes for in-editor inspection without writing files +* `Bake & Export All` runs the full pipeline per variant: paint `mesh.colors32`, export `{base}_{suffix}.fbx`, instantiate the source prefab, swap `MeshFilter.sharedMesh` references to the new sub-meshes by name, and save `{base}_{suffix}.prefab` (full clone, not a Prefab Variant) +* Collision meshes are skipped automatically via `MeshHygieneUtility.IsCollisionNodeName` +* Existing files are overwritten — use git to roll back unwanted variants + ## Key characteristics * **Repack, not full unwrap** — preserves existing UV0 shell structure * **LOD-aware UV transfer** — transfers UV2 through UV0 shell correspondence * **Collision from any LOD** — generate collision meshes from source LOD geometry * **Dual save** — results persist as Unity assets and in FBX exports -* **Vertex AO** — GPU hemisphere depth sampling for per-vertex ambient occlusion +* **Vertex Color Baking** — AO (GPU hemisphere depth sampling) and Solid Color batch FBX + Prefab variant export * **Diagnostics included** — transfer quality, shell visualization, wireframe preview ## Dependencies @@ -141,7 +155,7 @@ git clone https://github.com/SashaRX/UnityLodUvLightmapTransfer.git com.sasharx. - Transfer tab: per-LOD quality report after a transfer run - **LOD Gen**: Configure ratios → Generate LODs (or auto-create LODGroup from renderers) - **Collision**: Choose mode → Generate → Apply to Scene / Save to Sidecar - - **Vertex AO**: Configure samples → Bake → Apply to vertex colors/UVs + - **Vertex Color Baking**: AO mode — Configure samples → Bake → Apply to vertex colors/UVs. Solid Color mode — Add `(Color, suffix)` variants → Bake & Export All to write `_Red.fbx` + `_Red.prefab` per variant ## Requirements