diff --git a/.claude/skills/_archive/migration-and-refactor-planner.pre-overhaul.md b/.claude/skills/_archive/migration-and-refactor-planner.pre-overhaul.md deleted file mode 100644 index 1e9bd8ba..00000000 --- a/.claude/skills/_archive/migration-and-refactor-planner.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: migration-and-refactor-planner -description: Используй при планировании многошаговых рефакторингов — декомпозиция монолитных файлов, смена минимальной версии Unity, переименование namespace, добавление Tests~/Documentation~/Samples~. ---- - -# Migration & Refactor Planner - -Плейбуки: декомпозиция монолитов (по блоку за коммит), добавление тестов (Tests/Editor + asmdef + smoke), извлечение в UPM (структура + namespace + git URL), смена версии Unity, ренейм namespace. Каждый шаг = отдельный коммит. diff --git a/.claude/skills/_archive/repo-auditor.pre-overhaul.md b/.claude/skills/_archive/repo-auditor.pre-overhaul.md deleted file mode 100644 index 539e241a..00000000 --- a/.claude/skills/_archive/repo-auditor.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: repo-auditor -description: Используй при запросе аудита, сканирования или отчёта о здоровье Unity UPM-пакета. Только отчёт, без правок. ---- - -# Repo Auditor - -Сканируй репо и выдавай таблицу OK/WARN/FAIL по: package.json, asmdef, структура, namespace, размеры файлов, README/CHANGELOG, LICENSE, CI, антипаттерны. Не пиши фиксы. diff --git a/.claude/skills/_archive/unity-assetdatabase-tools.pre-overhaul.md b/.claude/skills/_archive/unity-assetdatabase-tools.pre-overhaul.md deleted file mode 100644 index 1e724a63..00000000 --- a/.claude/skills/_archive/unity-assetdatabase-tools.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: unity-assetdatabase-tools -description: Используй при написании AssetPostprocessor, батчинге AssetDatabase-операций с StartAssetEditing, управлении импортом или генерации HideAndDontSave-ассетов. ---- - -# AssetDatabase Tools - -Батчинг: StartAssetEditing в try/finally. AssetPostprocessor: GetPostprocessOrder явно + bypass HashSet. Progress bar: DisplayCancelableProgressBar + ClearProgressBar в finally. ЗАПРЕЩЕНО: StartAssetEditing без try/finally, Refresh внутри цикла, Resources.Load в Editor, FindAssets без t: фильтра. diff --git a/.claude/skills/_archive/unity-editor-tooling.pre-overhaul.md b/.claude/skills/_archive/unity-editor-tooling.pre-overhaul.md deleted file mode 100644 index 9b78fa77..00000000 --- a/.claude/skills/_archive/unity-editor-tooling.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: unity-editor-tooling -description: Используй при создании EditorWindow, MenuItem, [InitializeOnLoad], [OnOpenAsset], AssetModificationProcessor или других точек входа Unity Editor. ---- - -# Unity Editor Tooling - -Точки входа: EditorWindow (GetWindow/CreateInstance), AssetModificationProcessor (OnWillSaveAssets, IsOpenForEdit), [OnOpenAsset], [InitializeOnLoad]. Запреты: ручной JSON, UnityWebRequest без timeout, статическое состояние без контракта, тяжёлые операции в OnGUI, Editor API в Runtime asmdef. diff --git a/.claude/skills/_archive/unity-package-architect.pre-overhaul.md b/.claude/skills/_archive/unity-package-architect.pre-overhaul.md deleted file mode 100644 index 35e5e300..00000000 --- a/.claude/skills/_archive/unity-package-architect.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: unity-package-architect -description: Используй при проектировании или исправлении package.json, asmdef, структуры Editor/Runtime/Tests~/Samples~/Documentation~/Native~, или ограничений версии Unity. ---- - -# Unity Package Architect - -Эталон: package.json (com.company.name, semver, LTS unity, нет нестандартных полей), asmdef (Company.Pkg.Editor, includePlatforms:["Editor"]), Editor/Runtime/Tests~/Documentation~/Samples~/Native~. Нейминг: Company.PackageName. Для монорепо: git URL + ?path=subfolder. diff --git a/.claude/skills/_archive/unity-package-reviewer.pre-overhaul.md b/.claude/skills/_archive/unity-package-reviewer.pre-overhaul.md deleted file mode 100644 index cf24039b..00000000 --- a/.claude/skills/_archive/unity-package-reviewer.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: unity-package-reviewer -description: Используй при ревью диффа, PR или существующего файла на нарушения правил Unity-пакета. Указывает конкретный скилл, чьё правило нарушено. ---- - -# Unity Package Reviewer - -CRITICAL: .mesh, DestroyImmediate без Undo, мутация prefab без scope, StartAssetEditing без try/finally, мутация без Undo, target cast, Editor в Runtime. HIGH: ручной JSON, нет timeout, забытый ApplyModifiedProperties, нет bypass, >50КБ, хардкод. LOW: нет #if, нет progress bar, namespace без префикса. diff --git a/.claude/skills/_archive/unity-serialized-workflow.pre-overhaul.md b/.claude/skills/_archive/unity-serialized-workflow.pre-overhaul.md deleted file mode 100644 index 214c06bd..00000000 --- a/.claude/skills/_archive/unity-serialized-workflow.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: unity-serialized-workflow -description: Используй при работе с SerializedObject/SerializedProperty, создании CustomEditor/PropertyDrawer или сравнении сериализованных значений. ---- - -# Unity Serialized Workflow - -Цикл: Update() → FindProperty → ApplyModifiedProperties(). ЗАПРЕЩЕНО: мутация target cast. BeginProperty/EndProperty в PropertyDrawer обязательны. Read-only доступ через new SerializedObject(obj).FindProperty(path). diff --git a/.claude/skills/_archive/unity-undo-prefab-safety.pre-overhaul.md b/.claude/skills/_archive/unity-undo-prefab-safety.pre-overhaul.md deleted file mode 100644 index e1767980..00000000 --- a/.claude/skills/_archive/unity-undo-prefab-safety.pre-overhaul.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: unity-undo-prefab-safety -description: Используй при мутации prefab-ассетов, записи Undo-групп, редактировании prefab-оверрайдов или клонировании asset-backed мешей перед модификацией. ---- - -# Undo и Prefab Safety - -Undo-группы: SetCurrentGroupName → GetCurrentGroup → CollapseUndoOperations. RAII: UndoGroupScope. Prefab: EditPrefabContentsScope. Temp-instance: InstantiatePrefab → try {SaveAsPrefabAsset} finally {DestroyImmediate}. Оверрайды: Get/SetPropertyModifications. Mesh: только .sharedMesh + клон. ЗАПРЕЩЕНО: .mesh, LoadAssetAtPath→мутация→SaveAssets, DestroyImmediate без Undo. diff --git a/.claude/skills/_shared/naming-conventions.md b/.claude/skills/_shared/naming-conventions.md index 1e4eb46c..851a94d5 100644 --- a/.claude/skills/_shared/naming-conventions.md +++ b/.claude/skills/_shared/naming-conventions.md @@ -16,7 +16,7 @@ Rationale: Unity's Package Manager is case-sensitive on disk on Linux and inside Every namespace block in `.cs` files under `Editor/`, `Runtime/`, and `Tests/` must begin with `SashaRX.`. The `` segment is PascalCase and matches the repository folder name. - Canonical: `namespace SashaRX.PrefabDoctor`, `namespace SashaRX.UnityMeshLab.Editor`, `namespace SashaRX.UnityMeshLab.Tests` -- Prohibited: single-segment bare namespaces (`namespace LightmapUvTool`), three-or-more-segment vendor prefixes (`Com.SashaRX.PrefabDoctor`), arbitrary English words as root (`MyTools.PrefabDoctor`) +- Prohibited: single-segment bare namespaces (`namespace LegacyTool`), three-or-more-segment vendor prefixes (`Com.SashaRX.PrefabDoctor`), arbitrary English words as root (`MyTools.PrefabDoctor`) Rationale: single-segment namespaces collide with C# type names in IntelliSense, conflict with `using` aliases, and violate the reverse-DNS-analog convention used by every public Unity UPM package (UniTask, R3, NaughtyAttributes, MessagePipe, VContainer). A repository currently using a bare namespace must migrate via `migration-and-refactor-planner` before merging new code. diff --git a/.claude/skills/_shared/version-gates.md b/.claude/skills/_shared/version-gates.md index eab97ac9..e6ecf204 100644 --- a/.claude/skills/_shared/version-gates.md +++ b/.claude/skills/_shared/version-gates.md @@ -24,7 +24,7 @@ There is no patch-level `_OR_NEWER` directive. Use asmdef `versionDefines` with { "name": "com.unity.formats.fbx", "expression": "[5.0.0,6.0.0)", - "define": "LIGHTMAP_UV_TOOL_FBX_EXPORTER" + "define": "UNITY_MESH_LAB_FBX_EXPORTER" }, { "name": "Unity", diff --git a/.claude/skills/repo-auditor/SKILL.md b/.claude/skills/repo-auditor/SKILL.md index a2ccd3b8..2a549ae2 100644 --- a/.claude/skills/repo-auditor/SKILL.md +++ b/.claude/skills/repo-auditor/SKILL.md @@ -11,7 +11,7 @@ Read-only audit skill for Unity UPM repositories. Produces a prioritized Markdow Covered here: -- Skills directory structure and coherence with `.claude/skills/skills-overhaul-plan.md` if present. +- Skills directory structure and internal reference coherence. - Agent docs (`AGENTS.md`, `CLAUDE.md`) presence and consistency with each other. - `.github/workflows/` presence for Unity CI and release automation. - `LICENSE`, `CHANGELOG.md`, `README.md` presence and format. @@ -110,7 +110,7 @@ grep -n '^\*\.meta$' .gitignore 2>/dev/null Every finding should name a follow-up target: -- Skills directory findings → this skill (`repo-auditor`) and `skills-overhaul-plan.md` if present. +- Skills directory findings → this skill (`repo-auditor`). - Agent doc findings → manual resolution; this skill does not author. - CI findings → `unity-ci-validation`. - package.json / asmdef findings → `unity-package-reviewer` for audit, `unity-package-architect` for fix. diff --git a/.claude/skills/repo-conventions/SKILL.md b/.claude/skills/repo-conventions/SKILL.md index 7bda3eb2..2d0a95ab 100644 --- a/.claude/skills/repo-conventions/SKILL.md +++ b/.claude/skills/repo-conventions/SKILL.md @@ -38,7 +38,7 @@ Located under `.github/workflows/`: ## Deviations from `_shared/naming-conventions.md` -None at the canonical-target level. Both historical deviations (bare `LightmapUvTool` namespace, `com.sasharx.lightmap-uv-tool` package id) were resolved in the 1.0.0 release. +None at the canonical-target level. Historical namespace and package-ID deviations were resolved in the 1.0.0 release. ## Primary domain vocabulary @@ -47,7 +47,7 @@ Terms that identify tasks as in-scope for this repo (used as description trigger - Mesh editor, mesh hygiene, mesh repacking. - Lightmap UV, UV2, baked lightmap, UV transfer. - LOD group, LOD UV workflow, LOD sibling detection. -- FBX export (gated by `LIGHTMAP_UV_TOOL_FBX_EXPORTER`). +- FBX export (gated by `UNITY_MESH_LAB_FBX_EXPORTER`). - Sidecar asset (`Uv2DataAsset` — persists UV2/collision data next to FBX). ## Repo-specific rules (from CLAUDE.md) @@ -55,14 +55,14 @@ Terms that identify tasks as in-scope for this repo (used as description trigger Shared with agents via `CLAUDE.md`: - No `using System.Text.RegularExpressions` in `LightmapTransferTool.cs` — use fully qualified `System.Text.RegularExpressions.Regex`. -- Log via `UvtLog.Info` / `UvtLog.Warn` / `UvtLog.Error` — prefixed `[LightmapUV]`. +- Log via `UvtLog.Info` / `UvtLog.Warn` / `UvtLog.Error` — prefixed `[MeshLab]`. - Use `Undo.RecordObject` / `Undo.AddComponent` / `Undo.DestroyObjectImmediate` for scene modifications. - Call `RestoreWorkingMeshes()` before clearing/switching LODGroup context. - Destroy temporary meshes (repacked, transferred, welded) when no longer needed. ## Migration history -- **1.0.0 (2026-04-20)** — package id renamed `com.sasharx.lightmap-uv-tool` → `com.sasharx.unitymeshlab`; namespace renamed `LightmapUvTool` → `SashaRX.UnityMeshLab`; repository URL corrected to `UnityMeshLab.git`. Downstream migration steps in `CHANGELOG.md`. +- **1.0.0 (2026-04-20)** — package ID, namespace, and repository URL were standardized to the current UnityMeshLab identity. Downstream migration steps are recorded in `CHANGELOG.md`. ## Further reading diff --git a/.claude/skills/skills-overhaul-plan.md b/.claude/skills/skills-overhaul-plan.md deleted file mode 100644 index 30af1978..00000000 --- a/.claude/skills/skills-overhaul-plan.md +++ /dev/null @@ -1,898 +0,0 @@ -# Overhauling `.claude/skills/` across three Unity UPM repositories: a paste-ready execution plan - -**TL;DR**: Your current `.claude/skills/` directory has the right bones but is skeletal content-wise, its `_shared/` and `_checklists/` folders are orphaned (no SKILL.md references them, so progressive disclosure is broken), skill descriptions are too short and terse to trigger reliably, and there is an unresolved namespace convention conflict (`Company.PackageName` vs. bare `LightmapUvTool`). The fix is a five-phase plan per repo: snapshot, restructure, rewrite English SKILL.md bodies under 500 lines with pushy third-person descriptions, wire up `_shared/` and `_checklists/` as Level-3 resources with one-level-deep references, resolve the namespace conflict by adopting `SashaRX.` as the canonical two-segment rule, and add three missing skills (`unity-ci-validation`, `unity-package-bootstrap`, and a per-repo `repo-conventions`). GitHub fetchability was blocked in this session, so the plan is parameterized by per-repo variables and ships with a Phase 0 discovery script that auto-fills them. - ---- - -## Part 1 — Research synthesis - -### 1.1 Anthropic canonical rules for skill authoring - -All quantitative claims below are verbatim from Anthropic canonical sources, with URLs. - -**Frontmatter schema** (from https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview and https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices): - -| Field | Rule | Source | -|---|---|---| -| `name` | Max 64 chars, lowercase letters/numbers/hyphens only, no XML, no reserved words `anthropic`/`claude` | Overview | -| `description` | Non-empty, max **1024 chars**, no XML | Overview | -| Point of view | **Third person**. "Processes Excel files and generates reports." Avoid "I can help you…" / "You can use this to…" | Best Practices | -| Claude Code budget | `description` + `when_to_use` truncated at **1,536 chars** in the listing; default budget is 1% of context or 8,000 chars fallback | https://code.claude.com/docs/en/skills | -| Claude Code optional fields | `when_to_use`, `allowed-tools`, `model`, `effort`, `paths`, `argument-hint`, `disable-model-invocation`, `user-invocable`, `context`, `agent`, `hooks`, `shell` | Claude Code docs | - -**Progressive disclosure — three levels** (verbatim, Overview): - -| Level | When loaded | Token cost | Content | -|---|---|---|---| -| 1 Metadata | Always at startup | ~100 tokens/skill | `name` + `description` from YAML | -| 2 Instructions | When triggered | Under 5,000 tokens | SKILL.md body | -| 3 Resources | As needed | Effectively unlimited | Bundled files, loaded only when referenced | - -**Body length** (Best Practices, verbatim): "Keep SKILL.md body under 500 lines for optimal performance. If your content exceeds this, split it into separate files using the progressive disclosure patterns described earlier." - -**Reference depth** (Best Practices, verbatim): "Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md to ensure Claude reads complete files when needed." Files longer than 100 lines must have a table of contents. - -**Bundled-resource folder conventions** (verbatim from anthropics/skills skill-creator): - -``` -skill-name/ -├── SKILL.md (required) -├── scripts/ - Executable code for deterministic/repetitive tasks -├── references/ - Docs loaded into context as needed -└── assets/ - Files used in output (templates, icons, fonts) -``` - -**Pushy descriptions** — Anthropic explicitly acknowledges the undertriggering problem. Verbatim from skill-creator SKILL.md at https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md: *"Currently Claude has a tendency to 'undertrigger' skills — to not use them when they'd be useful. To combat this, please make the skill descriptions a little bit 'pushy'."* The canonical template is: what the skill does + **"Use when …"** + explicit trigger nouns/verbs/file types, in third person, on a single YAML line. - -**Canonical examples** (from public docs): - -- `description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.` -- `description: Analyze Excel spreadsheets, create pivot tables, generate charts. Use when analyzing Excel files, spreadsheets, tabular data, or .xlsx files.` -- `description: Generate descriptive commit messages by analyzing git diffs. Use when the user asks for help writing commit messages or reviewing staged changes.` - -**Filesystem precedence** (Claude Code, verbatim): "When skills share the same name across levels, higher-priority locations win: **enterprise > personal > project**." Project skills live at `.claude/skills//SKILL.md`. Claude Code watches the directory and reloads within the session; creating a new top-level skills directory mid-session requires restart. - -**Auto-compaction** (Claude Code): re-attaches the most recent invocation of each skill, keeping the first **5,000 tokens**, with a combined **25,000-token** budget. Implication: standing instructions should be self-sufficient in the first 5k tokens. - -**Anti-patterns verified from Anthropic sources**: -- Vague names (`helper`, `utils`, `tools`) or vague descriptions (`Helps with documents`). -- Time-sensitive prose ("Before August 2025 use old API") — use `
` blocks instead. -- Windows backslash paths — "Always use forward slashes." -- Multiple competing approaches — "Don't present multiple approaches unless necessary." -- Heavy-handed ALL-CAPS MUSTs — "reframe and explain the reasoning." -- Deeply nested references — Claude may partial-read with `head -100` and miss content. - -**Practitioner pitfalls worth catching in CI**: - -- Jesse Vincent (fsck.com, 2025-12-17): the default `SLASH_COMMAND_TOOL_CHAR_BUDGET` is 15,000 chars in Claude Code 2.0.70; overflow is silent and the system prompt instructs Claude *never* to use unlisted skills. -- Scott Spence: Prettier reflows single-line YAML descriptions into folded scalars, which the parser can reject — pin with `# prettier-ignore` or add YAML to Prettier's ignore file. -- Ivan Seleznov's 650-trial A/B: passive "Use when…" phrasing averages ~77% activation; directive "ALWAYS invoke this skill when … Do not X directly" reaches ~100% — but warns about *directive saturation* if every skill uses it. Recommendation: directive form for the three or four most-critical skills, third-person "Use when…" for the rest. -- Anthropic's own GitHub issues #37, #202, #249 flag that frontmatter keys outside the spec (`version`, `keywords`, `tags`, `author`) break validation on Claude.ai. Keep frontmatter to `name`, `description`, `license` (optional), `allowed-tools` (optional), `metadata` (optional), plus Claude Code extensions where needed. - -### 1.2 Cross-tool Unity AI rules research - -Six recurring Unity topics appear across Cursor (`.cursorrules` legacy, `.cursor/rules/*.mdc` modern), Windsurf (`.windsurfrules`), Copilot (`.github/copilot-instructions.md`), Aider (`CONVENTIONS.md`), and Cline (`.clinerules/`): - -| Theme | What consistently appears | Translates to | -|---|---|---| -| Editor mutations | `Undo.RecordObject`, `Undo.RegisterCreatedObjectUndo`, `EditorUtility.SetDirty`, `PrefabUtility.RecordPrefabInstancePropertyModifications`, `AssetDatabase.Refresh` | `unity-undo-prefab-safety` + `unity-assetdatabase-tools` (keep both; orthogonal) | -| UPM layout | `package.json`, asmdef boundaries, Runtime/Editor/Tests split, `Samples~`, `Documentation~` | `unity-package-architect` | -| Serialization | `[SerializeField]`, `[SerializeReference]`, `ISerializationCallbackReceiver`, prefab overrides, `.meta`/GUID | `unity-serialized-workflow` | -| Editor tooling | `EditorWindow`, `CustomEditor`, `PropertyDrawer`, `[MenuItem]`, `[InitializeOnLoad]` | `unity-editor-tooling` | -| Runtime performance | `GetComponent` caching, coroutines, Jobs/Burst, pooling, profiler markers | Out of scope for these three repos (editor tools). Defer. | -| Naming/style | `m_` prefix, PascalCase, `#region Unity Lifecycle`, `Author.Package` namespaces | Merge into `_shared/naming-conventions.md` + per-repo `repo-conventions` skill | - -**Structural lesson** from Cursor's `.cursor/rules/*.mdc` trend and Windsurf orientation documents: authors have moved from monolithic always-on rules to **many short rules with tight scoping** (globs, file patterns, directive descriptions). Claude Code skills already support this natively via the `paths` frontmatter field and description-based routing. Use `paths` on skills that are tied to file patterns (e.g. `paths: ["**/package.json", "**/*.asmdef"]` on `unity-package-architect`). - -**The Undo/SetDirty/AssetDatabase cluster is the single most-repeated editor topic across all surveyed tools** (Windsurf's CoderGamester/mcp-unity rules, Cursor's phucthai97/CursorRule_Unity, etc.). It deserves dedicated progressive-disclosure treatment — which is exactly what `unity-undo-prefab-safety` should do. - -**Unity trigger vocabulary that recurred in ≥3 tool corpora** (use these in descriptions and `paths`): -- File extensions: `.cs`, `.asmdef`, `.asmref`, `.prefab`, `.unity`, `.asset`, `.meta`, `package.json` -- Folders: `Assets/`, `Packages/`, `Runtime/`, `Editor/`, `Tests/`, `Samples~/`, `Documentation~/` -- APIs: `MonoBehaviour`, `ScriptableObject`, `EditorWindow`, `Editor`, `PropertyDrawer`, `AssetPostprocessor`, `AssetDatabase`, `EditorUtility.SetDirty`, `Undo.RecordObject`, `PrefabUtility`, `SerializedObject`/`SerializedProperty`, `PrefabStage` -- Attributes: `[SerializeField]`, `[SerializeReference]`, `[CustomEditor]`, `[CustomPropertyDrawer]`, `[MenuItem]`, `[InitializeOnLoad]`, `[ContextMenu]`, `[CreateAssetMenu]` - -### 1.3 Unity Editor API verification (2021.3 → 2022.3 → Unity 6) - -Each claim below has a Unity docs URL; use these directly in `_shared/` and skill bodies as authoritative references. - -**AssetDatabase batching** (https://docs.unity3d.com/ScriptReference/AssetDatabase.StartAssetEditing.html): -- Calls are reference-counted; must be paired in `try/finally`. Unreleased counter = unresponsive editor. -- Assets created between Start/Stop are **not fully imported** until `StopAssetEditing` returns — APIs against them mid-batch may misbehave. -- **Unity 6 adds `AssetDatabase.AssetEditingScope`** (IDisposable, usable in a `using` block). Use `#if UNITY_6000_0_OR_NEWER`. -- Never call `StartAssetEditing` from `EditorApplication.update` without a same-tick `StopAssetEditing` — breaks the editor update cycle (https://issuetracker.unity3d.com/issues/assetdatabase-dot-startassetediting-inside-editorapplication-dot-update-without-assetdatabase-dot-stopassetediting-breaks-the-editor). - -**AssetPostprocessor** (https://docs.unity3d.com/ScriptReference/AssetPostprocessor.html): -- `GetPostprocessOrder()` applies to per-asset callbacks, **not** to `OnPostprocessAllAssets`. For that, use assembly dependency attributes. -- Unity 2021.2+ adds `didDomainReload` overload to `OnPostprocessAllAssets`. -- Increment `GetVersion()` when behavior changes to invalidate import cache. -- Recursion guard: static `HashSet` of paths currently being processed, `try/finally` remove. - -**Prefab editing** (https://docs.unity3d.com/ScriptReference/PrefabUtility.LoadPrefabContents.html): -- Preferred API since 2020.1: `using (var scope = new PrefabUtility.EditPrefabContentsScope(path)) { ... }`. -- `PrefabStage` namespace moved from `UnityEditor.Experimental.SceneManagement` to `UnityEditor.SceneManagement` in 2021+; gate with `#if UNITY_2021_2_OR_NEWER`. -- For instance edits: `Undo.RecordObject(obj, name)` then `PrefabUtility.RecordPrefabInstancePropertyModifications(obj)` — **in that order, always both**. - -**Undo system** (https://docs.unity3d.com/ScriptReference/Undo.html): -- `Undo.RecordObject` does NOT capture parenting, `AddComponent`, or destruction — use `RegisterCompleteObjectUndo`, `Undo.AddComponent`, `Undo.DestroyObjectImmediate` respectively. -- `Undo.RegisterCreatedObjectUndo` must be called **after creation, before modifications**, to avoid losing subsequent `RecordObject` entries. -- Grouping: `IncrementCurrentGroup()` → work → `SetCurrentGroupName(name)` → `CollapseUndoOperations(groupIndex)`. - -**SerializedObject** (https://docs.unity3d.com/ScriptReference/SerializedObject.html): -- `Update()` at start of `OnInspectorGUI`; `ApplyModifiedProperties()` at end (records undo + SetDirty automatically). -- Never call `Update()` mid-modification — it discards unapplied changes. -- `EditorGUI.BeginChangeCheck()`/`EndChangeCheck()` detects GUI interaction, not property change — always pair with `ApplyModifiedProperties`. - -**Mesh safety** (https://docs.unity3d.com/ScriptReference/MeshFilter-sharedMesh.html): -- `MeshFilter.mesh` in edit mode clones and leaks; Unity warns. Use `sharedMesh`. -- Editing `sharedMesh` mutates the asset for **all instances** and is not undoable by `Undo.RecordObject` alone — clone first with `Instantiate(meshFilter.sharedMesh)`. -- `Mesh.UploadMeshData(true)` frees CPU-side copy (non-readable after). - -**Asmdef** (https://docs.unity3d.com/6000.1/Documentation/Manual/assembly-definition-file-format.html): -- `includePlatforms` and `excludePlatforms` are mutually exclusive — one must be empty. -- Editor-only: `"includePlatforms": ["Editor"]`. -- `versionDefines` expression uses interval notation: `[1.7,2.4.1]` or bare `7.1.0` (≥). Targets: package name, module name, or `"Unity"`. -- `UNITY_X_Y_OR_NEWER` defines exist since 5.3.4 but have **no patch-level granularity**; use `versionDefines` with `"name": "Unity"` if patch-level is needed. - -**UPM package.json** (https://docs.unity3d.com/Manual/upm-manifestPkg.html): -- `name` must be reverse-DNS and match folder name. -- `unity: "2021.3"` targets a LTS minor; `unityRelease: "0b5"` optionally narrows. -- `samples` array points to `Samples~/Folder` subpaths; UPM copies into `Assets/Samples/` on import. - -**Tilde-hidden folders** (https://docs.unity3d.com/Manual/cus-layout.html): any folder ending in `~` (or starting with `.`) is ignored by AssetDatabase — no `.meta` generated. Canonical use: `Samples~`, `Documentation~`, `Tests~` (if you ship tests but don't want them compiled in consumer projects). - -**.meta and file operations** (https://docs.unity3d.com/Manual/AssetMetadata.html): -- Always use `AssetDatabase.MoveAsset`/`RenameAsset`/`CopyAsset` — direct filesystem ops break GUID links. -- `AssetDatabase.MoveAsset` returns `""` on success, error string otherwise. -- `RenameAsset` cannot change extension. -- Always commit `.meta` files to git. - -**CI / batch mode** (https://docs.unity3d.com/Packages/com.unity.test-framework@2.0/manual/reference-command-line.html): -- `-batchmode -nographics -quit -projectPath -logFile -executeMethod Ns.Class.Method`. -- Test Runner: `-runTests -testPlatform EditMode|PlayMode -testResults `. -- Detect in code: `Application.isBatchMode`. - -### 1.4 Three-repo status report - -GitHub fetchability was blocked in this session at tool level, so the following is **partial** and must be completed by Phase 0's discovery script. What IS verified: - -| Repo | Canonical casing | Confirmed to exist | Language | Role | -|---|---|---|---|---| -| PrefabDoctor | `PrefabDoctor` (PascalCase) | ✅ pinned on profile | C# | "Unity Editor tool: nested prefab override conflicts, project-wide prefab health scanner" | -| UnityMeshLab | `UnityMeshLab` (PascalCase) | ✅ pinned on profile, 1 star | C# | Mesh lab tooling (scope unverified) | -| UnityLodUvLightmapTransfer | unconfirmed | ❓ | — | — | -| lightmap-uv-tool | unconfirmed | ❓ | — | — | -| HLODSystem (fork of Unity-Technologies) | verified | ✅ | C# | LOD system fork | - -The profile counter shows **9 public repos** total. Only 3 are pinned; the remaining 5 (plus HLODSystem) were not enumerable. **Working hypothesis**: `unitymeshlab` (lowercase in your original message) is the same repo as `UnityMeshLab`; `UnityLodUvLightmapTransfer` and `lightmap-uv-tool` may be earlier or rename-aliased names for the same package, or may be two of the five non-pinned repos. Phase 0 of the execution plan resolves this deterministically. - -**Critical**: the GitHub URL path is case-insensitive for redirects, but `raw.githubusercontent.com` paths are **case-sensitive**. Use `PrefabDoctor` and `UnityMeshLab` (PascalCase) in all automation. - ---- - -## Part 2 — Skill architecture redesign - -### 2.1 Per-skill disposition - -| Existing skill | Disposition | Rationale | -|---|---|---| -| `unity-editor-tooling` | **Rewrite** | Keep as umbrella for `EditorWindow`/`CustomEditor`/`[MenuItem]`; tighten description with trigger vocabulary; reference `_shared/` | -| `unity-assetdatabase-tools` | **Rewrite** | High-value skill, currently 1–3 lines. Expand with StartAssetEditing/StopAssetEditing, MoveAsset/RenameAsset, AssetPostprocessor | -| `unity-undo-prefab-safety` | **Rewrite + split references** | Becomes the flagship "editor mutation safety" skill. Reference `_checklists/undo-safety.md` and `_checklists/prefab-safety.md` | -| `unity-serialized-workflow` | **Rewrite** | Cover SerializedObject/Property/PropertyDrawer. Reference `_shared/version-gates.md` for 2021.3/2022.2/6 differences | -| `unity-package-architect` | **Rewrite** | Authoritative on UPM layout + package.json + asmdef. Reference `_template/package-template/` | -| `unity-package-reviewer` | **Keep + rewrite body** | Complementary to architect (review vs. author). Reference `_checklists/package-release.md` | -| `migration-and-refactor-planner` | **Rewrite + narrow scope** | Currently too generic. Scope to Unity-package migrations (namespace rename, asmdef restructure, Unity version bump) | -| `repo-auditor` | **Rewrite + narrow scope** | Scope to Unity UPM repo audits specifically; reference all checklists | -| **NEW** `unity-ci-validation` | **Create** | GameCI workflows, batch mode, `-runTests`, test result parsing, semver gate | -| **NEW** `unity-package-bootstrap` | **Create** | Entry point for `_template/package-template/`; creates a new package from template | -| **NEW** `repo-conventions` *(per repo)* | **Create** | Per-repo namespace, package name, CI specifics. Tiny file (≤ 80 lines). Resolves the global vs. local conventions tension | - -### 2.2 Orchestration layer - -Rules for cross-skill references and shared files, so `_shared/` and `_checklists/` stop being orphans: - -1. **Every skill body ends with a "Further reading" section** that links to the two or three `_shared/` and `_checklists/` files it depends on. This is Anthropic's canonical one-level-deep pattern. -2. **`_shared/` files are loaded as Level-3 references** — short (≤ 150 lines), topical, canonical. Only SKILL.md files reference them; `_shared/` files never reference each other (avoids nested references Anthropic warns against). -3. **`_checklists/` files are action-ready** — imperative bullets, each with a verification command in backticks, intended for Claude to follow literally. They ARE nested references, but only from a SKILL.md, never from `_shared/`. -4. **`_template/package-template/` is accessed exclusively via `unity-package-bootstrap`** — which copies the template, performs variable substitution, and verifies the result. -5. **`repo-conventions` SKILL.md in each repo** overrides `_shared/naming-conventions.md` where they conflict and links to `_shared/naming-conventions.md` for everything else. - -### 2.3 Resolving the namespace-conventions conflict - -Two stances: -- **Stance A (strict):** enforce `SashaRX.` in `_shared/naming-conventions.md` and fix `lightmap-uv-tool`'s bare `LightmapUvTool` namespace via a migration. -- **Stance B (lenient):** relax `_shared/naming-conventions.md` to allow either pattern with a documented carve-out. - -**Recommendation: Stance A.** Reasoning: bare single-segment namespaces are a known .NET anti-pattern — they collide with type names in IntelliSense, conflict with `using` aliases, and are explicitly discouraged by Microsoft's C# guidelines. Every surveyed Unity UPM package (UniTask, R3, NaughtyAttributes, MessagePipe, VContainer) uses `Author.Package` or `Company.Package`. Canonical rule to write: - -> **Namespaces must follow `SashaRX.` (two segments minimum).** Example: `SashaRX.PrefabDoctor`, `SashaRX.UnityMeshLab`. Single-segment namespaces like `LightmapUvTool` are prohibited because they collide with C# type names and violate the reverse-DNS-analog convention used by every public Unity UPM package. The asmdef name, `rootNamespace`, and folder name under `Editor/`/`Runtime/` must all match. If a repository currently uses a bare namespace, apply migration via `migration-and-refactor-planner` before merging any new code. - -The execution plan includes a migration step for any repo found to use a bare namespace. - ---- - -## Part 3 — Per-skill rewrite specifications - -Frontmatter conventions used below: -- Third person, single YAML line for `description`. -- Start description with imperative verb describing what the skill *does*, then `Use when …` clause with concrete trigger words (file extensions, API names, domain nouns). -- Target ~200–300 chars per description to leave room inside the 1,024 limit and the 1,536 Claude-Code combined cap. -- `paths` added where a file-pattern trigger makes sense (Claude Code extension). - -### 3.1 `unity-editor-tooling` - -```yaml ---- -name: unity-editor-tooling -description: Write and review Unity Editor-only code — EditorWindow, CustomEditor, PropertyDrawer, menu items, IMGUI and UI Toolkit inspectors. Use when creating files under an Editor/ folder, writing [MenuItem], [CustomEditor], [CustomPropertyDrawer], [InitializeOnLoad], or any code inside an asmdef with includePlatforms Editor. Not for AssetDatabase batching or prefab mutation — delegate to unity-assetdatabase-tools and unity-undo-prefab-safety. -paths: ["**/Editor/**/*.cs"] ---- -``` - -**Body outline** (~220 lines): -- Scope and what this skill is NOT for (delegations) -- Editor asmdef shape (canonical JSON block) -- EditorWindow lifecycle (OnEnable/OnDisable/OnGUI vs CreateGUI for UI Toolkit) -- CustomEditor skeleton (paired with SerializedObject workflow from sibling skill) -- PropertyDrawer vs DecoratorDrawer -- `[MenuItem]` path conventions, shortcut key conflicts, validation functions -- `[InitializeOnLoad]` / `[InitializeOnLoadMethod]` — when to use, cost implications -- Good/bad pattern pair: IMGUI minimum example vs. UI Toolkit minimum example -- Version gates: 2021.3 IMGUI-first, 2022.2+ UI Toolkit mature -- Further reading: `_shared/naming-conventions.md`, `_shared/version-gates.md`, `_shared/anti-patterns.md` - -### 3.2 `unity-assetdatabase-tools` - -```yaml ---- -name: unity-assetdatabase-tools -description: Safely batch AssetDatabase operations, move and rename assets preserving GUIDs, and write AssetPostprocessors with recursion guards. Use when code touches AssetDatabase, AssetImporter, AssetPostprocessor, .meta files, or performs bulk asset creation, import, move, copy, or delete. Always wrap batches in try/finally with StartAssetEditing/StopAssetEditing (or AssetEditingScope on Unity 6+). -paths: ["**/*AssetPostprocessor*.cs", "**/*Importer*.cs"] ---- -``` - -**Body outline** (~280 lines): -- The batching contract: `StartAssetEditing`/`StopAssetEditing` must be paired in `try/finally`; nest-counted. Reference [_checklists/batch-safety.md]. -- Unity 6 `AssetEditingScope` (disposable) with version gate snippet -- Do NOT call any AssetDatabase query API between Start/Stop expecting fresh results — imports are deferred. -- `MoveAsset`/`RenameAsset`/`CopyAsset` semantics; never `File.Move`. -- `AssetPostprocessor`: - - `GetPostprocessOrder`, `GetVersion` (increment on behavior change) - - OnPreprocess* / OnPostprocess* / `OnPostprocessAllAssets` (+ `didDomainReload` on 2021.2+) - - Recursion guard pattern with `HashSet` + `try/finally` - - `importer.userData` for round-trip state - - **Ship postprocessors as DLLs in production** (avoid compile-error import lockup) -- Good/bad pair: batch import of 10,000 textures with/without `StartAssetEditing` -- Further reading: `_checklists/batch-safety.md`, `_shared/version-gates.md`, `_shared/anti-patterns.md` - -### 3.3 `unity-undo-prefab-safety` - -```yaml ---- -name: unity-undo-prefab-safety -description: Make every editor mutation undoable and every prefab edit safe. Use when code modifies scene GameObjects, components, prefab assets, or prefab instance overrides, or when using Undo, PrefabUtility, PrefabStage, or EditorUtility.SetDirty. ALWAYS call Undo.RecordObject before mutation, PrefabUtility.RecordPrefabInstancePropertyModifications after instance edits, and EditPrefabContentsScope (2020.1+) for asset edits. Do not use File.* for assets — use AssetDatabase APIs (delegate to unity-assetdatabase-tools). -paths: ["**/Editor/**/*.cs"] ---- -``` - -**Body outline** (~320 lines): -- Three mutation contexts: scene instance, prefab instance override, prefab asset -- Decision table: which API for which context -- `Undo.RecordObject` what it covers (serialized property delta) vs doesn't (parenting, AddComponent, destruction) -- `Undo.RegisterCompleteObjectUndo` / `RegisterFullObjectHierarchyUndo` / `RegisterCreatedObjectUndo` (order matters!) -- `Undo.AddComponent` / `Undo.DestroyObjectImmediate` / `Undo.SetTransformParent` -- Grouping pattern: `IncrementCurrentGroup` → work → `SetCurrentGroupName` → `CollapseUndoOperations(GetCurrentGroup())` -- Prefab asset edit pattern: `PrefabUtility.EditPrefabContentsScope` (preferred) + manual pattern fallback -- Prefab instance override pattern: `RecordObject` then `RecordPrefabInstancePropertyModifications` — MUST be both, MUST be in that order -- `PrefabStage` API (namespace moved in 2021+; gate) -- Reference: [_checklists/undo-safety.md], [_checklists/prefab-safety.md] -- Further reading: `_shared/anti-patterns.md`, `_shared/version-gates.md` - -### 3.4 `unity-serialized-workflow` - -```yaml ---- -name: unity-serialized-workflow -description: Implement CustomEditor and PropertyDrawer classes with correct SerializedObject lifecycle. Use when writing CustomEditor, CustomPropertyDrawer, EditorWindow with Inspector-style panels, or any code using SerializedObject/SerializedProperty/FindProperty/FindPropertyRelative. Always call serializedObject.Update() first and ApplyModifiedProperties() last; never call Update() mid-modification. -paths: ["**/Editor/**/*.cs"] ---- -``` - -**Body outline** (~180 lines): -- Canonical `OnInspectorGUI` skeleton -- `FindProperty` vs `FindPropertyRelative` -- Multi-object editing: `new SerializedObject(targets)` -- `SerializedProperty` iteration (copy iterator; `NextVisible(true)`) -- `EditorGUI.BeginChangeCheck` + `EndChangeCheck` + `ApplyModifiedProperties` as the canonical change-detection triplet -- `OnValidate()` as the invariant-enforcement companion (flushed props bypass setters) -- `[SerializeField]` vs `[SerializeReference]` vs `ISerializationCallbackReceiver` -- When to use `ApplyModifiedPropertiesWithoutUndo` -- Shift-right-click trick for copying serialized paths (documentation aid) -- Further reading: `_shared/anti-patterns.md`, `_shared/version-gates.md` - -### 3.5 `unity-package-architect` - -```yaml ---- -name: unity-package-architect -description: Author Unity UPM package skeletons with canonical layout — package.json, asmdef boundaries, Runtime/Editor/Tests split, Samples~ and Documentation~ tilde-hidden folders. Use when creating a new UPM package, restructuring a package, editing package.json, or adding/splitting asmdefs. Namespaces follow SashaRX.; see _shared/naming-conventions.md. -paths: ["**/package.json", "**/*.asmdef", "**/*.asmref"] ---- -``` - -**Body outline** (~260 lines): -- Canonical folder tree (the one from §1.3) -- `package.json` field-by-field (with reverse-DNS name, SemVer, `unity` LTS target, `unityRelease`, `dependencies`, `samples` array) -- Asmdef pair pattern: `SashaRX.` (Runtime) + `SashaRX..Editor` (Editor, `includePlatforms: ["Editor"]`) + `SashaRX..Tests.Editor` + optional `SashaRX..Tests.Runtime` -- `versionDefines` pattern for conditional compilation on other UPM packages / Unity versions -- `defineConstraints` vs `versionDefines` — when to use which -- `Samples~` convention and `samples` array correspondence -- `Documentation~` convention -- `Tests~` vs `Tests/` — ship-or-skip decision -- `.gitignore`/`.npmignore` rules for UPM -- Good/bad pair: flat Assets/ layout vs. tilde-hidden conventional layout -- Further reading: `_shared/naming-conventions.md`, `_shared/version-gates.md`, `_template/package-template/` - -### 3.6 `unity-package-reviewer` - -```yaml ---- -name: unity-package-reviewer -description: Audit a Unity UPM package for release readiness — verify package.json, asmdef platform filters, CHANGELOG conformance, SemVer bump correctness, Samples~ wiring, and absence of Assets/-only references. Use when preparing a release, merging a version bump PR, reviewing a UPM package for publication, or inspecting package.json changes. Runs through _checklists/package-release.md end to end. -paths: ["**/package.json", "**/CHANGELOG.md"] ---- -``` - -**Body outline** (~200 lines): -- Review phases mapped to `_checklists/package-release.md` -- SemVer decision tree (patch/minor/major) with Unity-package-specific signals (asmdef reference added = minor; public API removed = major) -- CHANGELOG.md Keep-a-Changelog format check -- `samples` array vs `Samples~/` folder cross-check -- `docs~` presence + `documentationUrl` validity -- Asmdef platform-filter audit (no Editor asmdef leaking into Runtime deps) -- `dependencies` version range sanity -- Further reading: `_checklists/package-release.md`, `_shared/naming-conventions.md` - -### 3.7 `migration-and-refactor-planner` - -```yaml ---- -name: migration-and-refactor-planner -description: Plan a safe migration inside a Unity UPM package — namespace rename, asmdef restructure, Unity minimum-version bump, or API deprecation. Use when renaming a namespace across many files, splitting or merging asmdefs, bumping the "unity" field in package.json, or deprecating public API that downstream samples or tests depend on. Always sequence: snapshot → migrate → regenerate GUIDs only if unavoidable → run tests → bump SemVer. ---- -``` - -**Body outline** (~200 lines): -- Scope: intra-package migrations, not cross-repo. -- Phase template: snapshot (git tag), discovery (grep/asmdef scan), plan, execute, validate, commit, bump. -- Namespace rename: use `AssetDatabase.RenameAsset` for asmdef files; use `sed`/rename tool for C# `namespace` blocks; match asmdef `rootNamespace`. -- Asmdef split: preserve GUIDs by keeping the original asmdef file and creating new ones; never delete-and-recreate an asmdef with the same name. -- Unity minimum bump: update `package.json` `unity`; scan for APIs that existed in the old version but not the new; run matrix CI. -- API deprecation: `[Obsolete]` with `false` (warning) first, then `true` (error) next minor, then removal next major. -- Further reading: `_checklists/package-release.md`, `_shared/version-gates.md`, `_shared/naming-conventions.md` - -### 3.8 `repo-auditor` - -```yaml ---- -name: repo-auditor -description: Audit a Unity UPM repository's health — skill directory structure, AGENTS.md/CLAUDE.md coherence, CI workflow presence, LICENSE, CHANGELOG, package.json correctness, .gitignore safety. Use when onboarding a new repo, before a release, or when asked to "audit", "review the repo", or "check project health". Produces a prioritized findings report with line-referenced citations. ---- -``` - -**Body outline** (~180 lines): -- Audit dimensions (table): skills, agent docs, CI, license, changelog, package.json, asmdef, gitignore, samples, docs. -- Output format: Markdown report with severity (critical/warning/info) + file:line citations + suggested fix command. -- Calls out to `_checklists/package-release.md` for the release subset. -- Integration: can hand off findings as inputs to `migration-and-refactor-planner`. -- Further reading: all four `_checklists/*.md`, `_shared/anti-patterns.md` - -### 3.9 NEW — `unity-ci-validation` - -```yaml ---- -name: unity-ci-validation -description: Author and debug Unity CI workflows for UPM packages — GameCI actions, batch mode EditMode and PlayMode test runs, license activation, NUnit result parsing, and release gates on SemVer. Use when creating or editing .github/workflows/*.yml for Unity, diagnosing a failed CI run, or wiring semantic-release / GitHub Release automation. Knows -batchmode -nographics -runTests -testPlatform -testResults conventions and Application.isBatchMode guards. -paths: [".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"] ---- -``` - -**Body outline** (~240 lines): -- GameCI skeleton workflow (test + release job) with pinned action versions. -- License activation variants: personal (manual activation file) vs professional (serial secret). -- EditMode vs PlayMode matrix; `unity-versions` matrix across LTS. -- Result parsing: NUnit XML to GitHub Checks annotations. -- `-executeMethod` for custom build/export entrypoints; always guard with `Application.isBatchMode` to avoid blocking dialogs. -- Release automation via semantic-release with Unity-specific plugin config, or manual `npm version` + tag push. -- Secret management and minimum scopes. -- Further reading: `_checklists/package-release.md`, `_shared/anti-patterns.md` - -### 3.10 NEW — `unity-package-bootstrap` - -```yaml ---- -name: unity-package-bootstrap -description: Bootstrap a new Unity UPM package from the bundled _template/package-template/ — creates folder structure, substitutes {{PackageName}}, {{Namespace}}, {{DisplayName}}, {{UnityMinVersion}} placeholders, renames .template files, initializes git, and verifies the package imports cleanly. Use when the user asks to "create a new package", "scaffold a UPM package", "bootstrap a new Unity tool", or any request to start a new Unity editor extension. ---- -``` - -**Body outline** (~160 lines): -- Template inventory: what's in `_template/package-template/` -- Parameters: `{{PackageName}}` (PascalCase), `{{Namespace}}` (= `SashaRX.{{PackageName}}`), `{{PackageId}}` (= `com.sasharx.`), `{{DisplayName}}`, `{{Description}}`, `{{UnityMinVersion}}` (default `2021.3`), `{{Author}}` (default `SashaRX`), `{{License}}` (default `MIT`) -- Execution sequence: copy template → substitute → rename `.template` suffix → `git init` → `git add` → initial commit -- Post-bootstrap verification: package.json JSON-parseable, asmdef JSON-parseable, no `{{...}}` markers remain, file tree matches canonical layout -- Integration with `unity-ci-validation` to optionally add workflows -- Further reading: `unity-package-architect`, `_shared/naming-conventions.md` - -### 3.11 NEW — per-repo `repo-conventions` - -One SKILL.md per repo, intentionally small. - -```yaml ---- -name: repo-conventions -description: Canonical conventions for THIS repository — the package ID, namespace, Unity version target, CI workflow names, and any deviations from _shared/naming-conventions.md. Use at the start of any non-trivial task in this repo, when creating new C# files (to pick the correct namespace), when adding asmdefs, or when editing package.json. Overrides _shared/* where they conflict. ---- -``` - -**Body outline** (~60 lines, per-repo variables filled): - -``` -# repo-conventions (PrefabDoctor) - -## Identity -- Package ID: com.sasharx.prefabdoctor -- Display Name: Prefab Doctor -- Root Namespace: SashaRX.PrefabDoctor -- Unity Min Version: 2021.3 - -## Assemblies -- Runtime: SashaRX.PrefabDoctor (rootNamespace = SashaRX.PrefabDoctor) -- Editor: SashaRX.PrefabDoctor.Editor (includePlatforms: ["Editor"]) -- Tests (Editor): SashaRX.PrefabDoctor.Tests.Editor (includePlatforms: ["Editor"]) - -## CI Workflows -- .github/workflows/test.yml — EditMode/PlayMode on 2021.3, 2022.3, Unity 6 -- .github/workflows/release.yml — Semantic release on tag push - -## Deviations from _shared/ -- None. - -## Further reading -- _shared/naming-conventions.md -- _shared/version-gates.md -``` - -(Equivalent files for `UnityMeshLab` and for the lightmap-uv repo with its own variable values, filled in Phase 0.) - ---- - -## Part 4 — Execution plan for Claude Code - -This is a **paste-ready prompt** for Claude Code. Run it in each of the three repositories. Per-repo variables are resolved in Phase 0 by discovery, so the same prompt body works everywhere. - -### Parameters resolved by Phase 0 - -| Variable | Discovered from | Example (PrefabDoctor) | -|---|---|---| -| `$REPO_NAME` | `git remote get-url origin` basename, PascalCase | `PrefabDoctor` | -| `$PACKAGE_ID` | `package.json` `.name` | `com.sasharx.prefabdoctor` | -| `$DISPLAY_NAME` | `package.json` `.displayName` or inferred | `Prefab Doctor` | -| `$NAMESPACE` | scan `Editor/**/*.cs` + `Runtime/**/*.cs` first `namespace` token | `SashaRX.PrefabDoctor` | -| `$UNITY_MIN` | `package.json` `.unity` | `2021.3` | -| `$HAS_AGENTS_MD` | `test -f AGENTS.md` | `true`/`false` | -| `$HAS_CLAUDE_MD` | `test -f CLAUDE.md` | `true`/`false` | -| `$CI_WORKFLOWS` | `ls .github/workflows` | `test.yml release.yml` | -| `$DEFAULT_BRANCH` | `git symbolic-ref refs/remotes/origin/HEAD` | `main` | - -### Phase 0 — Pre-flight (≈ 3 minutes) - -**Goal:** fingerprint the repo, snapshot state, detect conflicts. - -```bash -# 0.1 Verify we're in a Unity UPM repo -test -f package.json || { echo "FAIL: no package.json at repo root"; exit 1; } - -# 0.2 Snapshot -git status --porcelain | tee /tmp/pre-overhaul-dirty.txt -git rev-parse HEAD | tee /tmp/pre-overhaul-sha.txt -git tag -a "pre-skills-overhaul-$(date +%Y%m%d)" -m "Snapshot before skills overhaul" - -# 0.3 Resolve parameters -REPO_NAME=$(basename -s .git "$(git remote get-url origin)") -PACKAGE_ID=$(jq -r '.name' package.json) -DISPLAY_NAME=$(jq -r '.displayName // empty' package.json) -UNITY_MIN=$(jq -r '.unity' package.json) -DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@' || echo main) - -# 0.4 Detect actual namespace in C# source -NAMESPACE=$(grep -hEr '^namespace ' --include='*.cs' Editor Runtime 2>/dev/null \ - | head -1 | awk '{print $2}' | tr -d '{' | xargs) -echo "Detected namespace: $NAMESPACE" - -# 0.5 Detect existing skills and agent files -ls -la .claude/skills 2>/dev/null -test -f AGENTS.md && echo "AGENTS.md present" -test -f CLAUDE.md && echo "CLAUDE.md present" -ls .github/workflows 2>/dev/null - -# 0.6 Detect namespace convention conflict -if [ -n "$NAMESPACE" ] && ! echo "$NAMESPACE" | grep -qE '^SashaRX\.'; then - echo "WARN: namespace '$NAMESPACE' does not match SashaRX. — migration required in Phase 2." -fi - -# 0.7 Write the parameter file used by later phases -cat > /tmp/skills-overhaul.env <` (SHA saved in `/tmp/pre-overhaul-sha.txt`), and `git tag -d pre-skills-overhaul-`. - -### Phase 1 — Structural changes - -**Goal:** Create missing directories, remove empty/broken skill stubs, prepare English scaffolding. - -```bash -cd .claude/skills - -# 1.1 Ensure standard directories exist (idempotent) -mkdir -p _checklists _shared _template/package-template/Editor _template/package-template/Tests/Editor - -# 1.2 For each existing skill, create references/ and scripts/ subfolders (even if unused initially) -for skill in unity-editor-tooling unity-assetdatabase-tools unity-undo-prefab-safety \ - unity-serialized-workflow unity-package-architect unity-package-reviewer \ - migration-and-refactor-planner repo-auditor; do - [ -d "$skill" ] && mkdir -p "$skill/references" "$skill/scripts" 2>/dev/null || true -done - -# 1.3 Create directories for new skills -mkdir -p unity-ci-validation/references unity-ci-validation/scripts -mkdir -p unity-package-bootstrap/references unity-package-bootstrap/scripts -mkdir -p repo-conventions - -# 1.4 Archive any existing non-English skill bodies for reference during rewrite -mkdir -p _archive -for skill in */SKILL.md; do - [ -f "$skill" ] && cp "$skill" "_archive/$(dirname "$skill").pre-overhaul.md" -done - -cd ../.. -git add .claude/skills -git status -``` - -**Commit:** -```bash -git commit -m "chore(skills): scaffold directory structure for overhaul - -- Add references/ and scripts/ subfolders under each skill -- Create directories for unity-ci-validation, unity-package-bootstrap, repo-conventions -- Archive pre-overhaul SKILL.md content under .claude/skills/_archive/" -``` - -**Verification:** -```bash -find .claude/skills -type d | sort -test -d .claude/skills/_archive -``` - -**Rollback:** `git reset --hard HEAD~1` (this commit only). - -### Phase 2 — Content rewrites - -**Goal:** Write English SKILL.md bodies per Part 3 specs. Populate `_shared/` and `_checklists/`. This is the largest phase; break into sub-commits. - -Instruction to Claude Code (in-session prompt after Phase 1 commit): - -> For each SKILL.md file listed in Part 3 of the plan, open the corresponding archive at `.claude/skills/_archive/.pre-overhaul.md` to understand the author's original intent, then overwrite `.claude/skills//SKILL.md` with: -> 1. Exactly the YAML frontmatter from Part 3. -> 2. A body that matches the outline in Part 3, under 500 lines, with fenced code blocks for good/bad pattern pairs, using forward-slash paths, in imperative third-person English, and ending with a **Further reading** section linking only to files in `_shared/` and `_checklists/` one level deep. -> 3. No XML tags, no `{{placeholder}}` markers left unresolved. -> After each SKILL.md, run `wc -l` to confirm under 500 lines and print the frontmatter. - -**2.1 Write `_shared/` files** (short, canonical, ≤ 150 lines each): - -**`_shared/naming-conventions.md`** (~90 lines): -- Reverse-DNS package ID (`com.sasharx.`), lowercase -- Two-segment namespace rule: `SashaRX.` (justified in one paragraph; bare single-segment namespaces prohibited) -- Asmdef naming: `SashaRX.`, `SashaRX..Editor`, `SashaRX..Tests.Editor` -- `rootNamespace` field of asmdef must match the `namespace` block of every `.cs` inside -- Folder naming: PascalCase for `.cs`-holding folders; tilde suffix for `Samples~`/`Documentation~`/`Tests~` -- File naming: `ClassName.cs` (one public class per file), `ClassName.PartName.cs` for partials -- Explicit deviation protocol: the per-repo `repo-conventions` SKILL.md documents any carve-out - -**`_shared/version-gates.md`** (~110 lines): -- Table of `UNITY_X_Y_OR_NEWER` vs what it enables (key rows: 2021.3, 2022.2, 2023.1, 6000.0) -- Gotchas: no patch-level `_OR_NEWER`; use asmdef `versionDefines` with `"name": "Unity"` for patch-level -- Asmdef `versionDefines` expression syntax (interval notation) -- Specific gate recipes used in this repo's skills: - - `EditPrefabContentsScope` — available since 2020.1 - - `PrefabStage` namespace moved in 2021.2 - - `AssetEditingScope` disposable — Unity 6000.0+ - - `OnPostprocessAllAssets` `didDomainReload` overload — 2021.2+ -- Policy: target `$UNITY_MIN` per `package.json`; anything newer needs explicit gating - -**`_shared/anti-patterns.md`** (~120 lines): -Sections: Undo pitfalls (RecordObject on parenting, missing RecordPrefabInstancePropertyModifications), AssetDatabase pitfalls (File.Move, unreleased StartAssetEditing, query between Start/Stop), Serialization pitfalls (Update() mid-mutation, first-person field names, leaking MeshFilter.mesh in edit mode), Packaging pitfalls (missing tilde on Samples, asmdef with both include+exclude, Unity minor bump without CI matrix update), Skill-authoring pitfalls (echo the cross-cutting ones from §1.1). Each item: one line symptom + one line root cause + one line fix. - -**2.2 Write `_checklists/` files** (imperative, with verification commands): - -**`_checklists/batch-safety.md`** (~60 lines): numbered pre-/mid-/post-batch checks. -**`_checklists/undo-safety.md`** (~60 lines): mutation-context decision tree + RecordObject-before-mutation enforcement. -**`_checklists/prefab-safety.md`** (~70 lines): asset vs override vs scene-instance paths. -**`_checklists/package-release.md`** (~100 lines): SemVer decision, CHANGELOG entry, asmdef audit, Samples array cross-check, CI green, tag-push procedure. - -**2.3 Write each SKILL.md body per Part 3.** Aim for ~200 lines per skill on average; none above 400. Verify individually. - -**Commit strategy for Phase 2 (one commit per logical group):** -```bash -git add .claude/skills/_shared -git commit -m "docs(skills): add canonical _shared/ references (naming, version gates, anti-patterns)" - -git add .claude/skills/_checklists -git commit -m "docs(skills): flesh out _checklists/ with imperative action items" - -git add .claude/skills/unity-editor-tooling .claude/skills/unity-assetdatabase-tools \ - .claude/skills/unity-undo-prefab-safety .claude/skills/unity-serialized-workflow -git commit -m "docs(skills): rewrite editor + assetdb + undo + serialization skills in English" - -git add .claude/skills/unity-package-architect .claude/skills/unity-package-reviewer -git commit -m "docs(skills): rewrite UPM architect + reviewer skills in English" - -git add .claude/skills/migration-and-refactor-planner .claude/skills/repo-auditor -git commit -m "docs(skills): rewrite migration-planner + repo-auditor with narrower scope" -``` - -**Per-SKILL.md verification:** -```bash -for skill in .claude/skills/*/SKILL.md; do - lines=$(wc -l < "$skill") - desc=$(awk '/^description:/{sub(/^description: */,""); print; exit}' "$skill") - desclen=${#desc} - echo "$skill lines=$lines desclen=$desclen" - [ "$lines" -le 500 ] || echo " WARN over 500 lines" - [ "$desclen" -le 1024 ] || echo " FAIL description over 1024 chars" - [ "$desclen" -ge 80 ] || echo " WARN description under 80 chars — likely too terse" -done -``` - -**Rollback:** each sub-commit can be reverted independently with `git revert `. - -### Phase 3 — New skills creation - -**Goal:** Create the three net-new skills from §3.9–§3.11. - -```bash -# 3.1 unity-ci-validation — reference workflow template in references/ -mkdir -p .claude/skills/unity-ci-validation/references -# (Claude Code writes SKILL.md per §3.9 + references/gameci-workflow.yml example) - -# 3.2 unity-package-bootstrap — script does template substitution -mkdir -p .claude/skills/unity-package-bootstrap/scripts -# (Claude Code writes SKILL.md per §3.10 + scripts/bootstrap.sh) - -# 3.3 repo-conventions — single SKILL.md with per-repo values filled from /tmp/skills-overhaul.env -source /tmp/skills-overhaul.env -# (Claude Code writes .claude/skills/repo-conventions/SKILL.md with $REPO_NAME, $PACKAGE_ID, -# $NAMESPACE, $UNITY_MIN substituted) -``` - -**Verification:** -```bash -grep -r '{{' .claude/skills/repo-conventions/ && echo "FAIL: unresolved placeholders" -jq -e . .claude/skills/unity-ci-validation/references/*.yml >/dev/null 2>&1 # if YAML is also JSON-parseable where applicable -``` - -**Commit:** -```bash -git add .claude/skills/unity-ci-validation .claude/skills/unity-package-bootstrap .claude/skills/repo-conventions -git commit -m "feat(skills): add unity-ci-validation, unity-package-bootstrap, and per-repo repo-conventions" -``` - -### Phase 3.5 — Namespace migration (conditional) - -Run **only** if Phase 0 step 0.6 logged the `WARN: namespace ... does not match` message (i.e., the repo currently uses a bare namespace like `LightmapUvTool`). - -```bash -source /tmp/skills-overhaul.env -OLD_NS="$NAMESPACE" -NEW_NS="SashaRX.${REPO_NAME}" - -# 3.5.1 Create snapshot branch -git checkout -b "chore/namespace-migration-$(date +%Y%m%d)" - -# 3.5.2 Replace in .cs files (word-boundary safe) -grep -rl --include='*.cs' -w "$OLD_NS" Editor Runtime Tests 2>/dev/null | while read f; do - sed -i.bak "s/\b$(printf '%s' "$OLD_NS" | sed 's/\./\\./g')\b/$NEW_NS/g" "$f" && rm "${f}.bak" -done - -# 3.5.3 Update asmdef rootNamespace + name fields -for asmdef in $(git ls-files '*.asmdef'); do - jq --arg old "$OLD_NS" --arg new "$NEW_NS" \ - 'if .rootNamespace == $old then .rootNamespace = $new else . end - | if (.name | startswith($old + ".") or . == $old) - then .name = ($new + (.name|ltrimstr($old))) else . end' \ - "$asmdef" > "$asmdef.tmp" && mv "$asmdef.tmp" "$asmdef" -done - -# 3.5.4 Rebuild in editor (Claude Code instructs user to reopen Unity or runs batch-mode compile check) -# 3.5.5 Run tests -# 3.5.6 Commit -git add . -git commit -m "refactor: migrate namespace $OLD_NS -> $NEW_NS - -Align with _shared/naming-conventions.md two-segment rule (SashaRX.). -Asmdef rootNamespace and name fields updated." -``` - -**Rollback:** `git checkout $DEFAULT_BRANCH && git branch -D chore/namespace-migration-*`. - -### Phase 4 — Validation - -Run a battery of checks and abort if any fail. - -```bash -# 4.1 Frontmatter validation — required keys, description length, name pattern -for skill in .claude/skills/*/SKILL.md; do - python3 - < $ref" - done -done - -# 4.4 No Windows backslashes in any skill file -grep -rnP '[^\\]\\[A-Za-z]' .claude/skills --include='*.md' && echo "WARN backslashes found" - -# 4.5 No leftover non-English or placeholder markers -grep -rnP '[А-Яа-яЁё]' .claude/skills --include='*.md' && echo "FAIL non-English text" -grep -rn '{{' .claude/skills --include='*.md' && echo "FAIL unresolved placeholders" - -# 4.6 Claude Code budget simulation — concat name+description per skill and ensure each is under 1536 -for skill in .claude/skills/*/SKILL.md; do - name=$(awk '/^name:/{print $2; exit}' "$skill") - desc=$(awk '/^description:/{sub(/^description: */,""); print; exit}' "$skill") - total=$(( ${#name} + ${#desc} )) - [ "$total" -le 1536 ] || echo "WARN $skill listing $total over 1536" -done - -# 4.7 Launch Claude Code and verify skills appear -echo "Manual step: run 'claude' and ask 'list available skills' — confirm all 11 skills listed." -``` - -**Commit (no content changes, only potential fixes from 4.x):** -```bash -git diff --exit-code .claude/skills && echo "Phase 4 clean" -``` - -### Phase 5 — Commit strategy and final push - -Phases 1–3.5 each already produced a commit. The final phase pushes and tags. - -```bash -git log --oneline "pre-skills-overhaul-$(date +%Y%m%d)"..HEAD -git push origin HEAD:refs/heads/chore/skills-overhaul -# Open PR; title: "chore(skills): overhaul .claude/skills/ to English + progressive disclosure" -# Body: link to this plan, list of commits, before/after line counts -``` - -After merge: -```bash -git tag -a skills-overhaul-complete -m "Skills overhaul complete for $REPO_NAME" -git push origin skills-overhaul-complete -``` - -### Per-repo customization section - -Fill the following table once Phase 0 runs in each repo. The plan body does not change; only these values do. - -| Variable | PrefabDoctor | UnityMeshLab | lightmap-uv-tool (if separate) | -|---|---|---|---| -| `REPO_NAME` | `PrefabDoctor` | `UnityMeshLab` | *(resolve in Phase 0)* | -| `PACKAGE_ID` | `com.sasharx.prefabdoctor` | `com.sasharx.unitymeshlab` | `com.sasharx.lightmap-uv-tool` | -| `DISPLAY_NAME` | Prefab Doctor | Unity Mesh Lab | Lightmap UV Tool | -| `NAMESPACE` | `SashaRX.PrefabDoctor` | `SashaRX.UnityMeshLab` | target `SashaRX.LightmapUvTool` (migrate if currently bare) | -| `UNITY_MIN` | *(from package.json)* | *(from package.json)* | *(from package.json)* | -| Primary domain | prefab override/variant analysis, prefab health scan | mesh lab tooling | lightmap UV transfer, LOD UV workflow | -| CI workflows | *(Phase 0 discovery)* | *(Phase 0 discovery)* | *(Phase 0 discovery)* | -| Bare-namespace migration needed | likely no | likely no | **yes, if AGENTS.md claim holds** | -| Primary domain vocabulary for `repo-conventions` description triggers | `nested prefab, override conflict, prefab health, prefab variant` | `mesh editor, LOD group, mesh combine` | `lightmap UV, LOD UV, UV transfer, uv2, baked lightmap` | - ---- - -## Part 5 — Risk register and open questions - -### 5.1 Risk register - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| AGENTS.md or CLAUDE.md contradicts new SKILL.md bodies | Medium | High — guidance drift confuses Claude | Phase 0 detects presence; Phase 2 sub-task reconciles and points AGENTS.md to the skills directory instead of duplicating rules | -| Private repo among the three — plan can't be validated externally | Possible | Medium | Plan is self-contained and runs locally; no external fetches required | -| SashaRX.github.io or HLODSystem accidentally receives the overhaul | Low | Medium | Phase 0's `test -f package.json` gate rejects non-UPM repos | -| Skill description drift from 1024-char limit after later edits | Medium | Low | Phase 4's validator is a copyable script; wire into a `pre-commit` hook | -| Prettier reflows YAML descriptions into multi-line folded scalars | Medium | High — skill invisible | Add `.prettierignore` entry `.claude/skills/**/SKILL.md` in Phase 1 | -| `SLASH_COMMAND_TOOL_CHAR_BUDGET` overflow hides skills | Medium | High | Phase 4 logs estimated total listing chars; recommend `export SLASH_COMMAND_TOOL_CHAR_BUDGET=30000` in user shell | -| Namespace migration breaks external callers of `lightmap-uv-tool` | Low | High for downstream users | Tag a `pre-namespace-migration-vX.Y.Z` release before the migration; publish migration note in CHANGELOG | -| `EditPrefabContentsScope` unavailable on some Unity version the repo still supports | Depends on `$UNITY_MIN` | Medium | `_shared/version-gates.md` documents the 2020.1 threshold; skills use `#if UNITY_2020_1_OR_NEWER` guards | -| Claude Code session started before the directory existed | Medium | Low | Anthropic docs: creating a top-level skills dir mid-session requires restart; document in a README stub | -| Skill bodies drift to >500 lines over time | Medium | Medium | Phase 4 validator caps; extract to `references/` as content grows | -| `repo-conventions` becomes stale vs `package.json` | High | Medium | Add a tiny CI step: `jq '.name, .unity' package.json` must match the values in `repo-conventions/SKILL.md` | - -### 5.2 Open questions for Sasha before Phase 3 - -1. **Confirm the three repos.** Are `UnityLodUvLightmapTransfer` and `lightmap-uv-tool` the same repo, renames of `UnityMeshLab`, or distinct? Run Phase 0 in what you believe to be the three repos and share the `/tmp/skills-overhaul.env` file from each. -2. **Namespace policy.** Do you accept Stance A (strict `SashaRX.`, migrate bare namespaces)? If you prefer Stance B (allow bare), `_shared/naming-conventions.md` content changes in Phase 2. -3. **Unity version target per repo.** Phase 0 reads `package.json` `unity`; confirm you want to keep that or bump to `2022.3` as part of this overhaul. -4. **CI expectations.** Should `unity-ci-validation` ship a concrete starter workflow into `.github/workflows/`, or only author guidance for when the user adds one manually? -5. **License uniformity.** Is MIT consistent across all three? The `_template/package-template/LICENSE` file needs a canonical copy. -6. **Description voice.** Default is third-person "Use when …". For the three most-critical skills (`unity-undo-prefab-safety`, `unity-assetdatabase-tools`, `unity-package-architect`), do you want directive "ALWAYS invoke …" phrasing per Seleznov's experiment? Marginally higher trigger rate at a directive-saturation risk. -7. **English-only reaffirmation.** All archived Russian content stays in `_archive/` and is not referenced from any active skill. Confirm that's fine. -8. **AGENTS.md relationship.** Keep AGENTS.md, shrink it to a pointer to `.claude/skills/`, or delete it? - -### 5.3 Success criteria - -The overhaul is successful when, in each of the three repos: - -1. `find .claude/skills -name SKILL.md | wc -l` returns **11** (8 existing rewritten + 3 new). -2. Phase 4 validator exits clean — no FAIL lines, warnings understood. -3. Opening the repo in Claude Code and asking "list available skills" returns all 11 with their English descriptions visible in full (no truncation from listing-budget overflow). -4. Asking Claude Code "how should I batch 10,000 texture reimports safely in this repo?" triggers `unity-assetdatabase-tools` without prompting. -5. Asking "rename the namespace" triggers `migration-and-refactor-planner` and references `_shared/naming-conventions.md`. -6. Creating a new test file under `Tests/Editor/` surfaces `repo-conventions` and `unity-serialized-workflow` guidance automatically (via `paths` matching). -7. `_shared/` and `_checklists/` are each referenced by at least three different SKILL.md files (progressive disclosure demonstrably wired). -8. Every SKILL.md body is ≤ 500 lines; every description is ≤ 1024 chars and in third person. -9. The namespace conflict is resolved — all `.cs` files under `Editor/` and `Runtime/` use `SashaRX.` (or the explicit deviation documented in `repo-conventions`). -10. A fresh clone + `claude` invocation with `SLASH_COMMAND_TOOL_CHAR_BUDGET` at default 15,000 still lists all 11 skills in full. - ---- - -## TL;DR — first three actions to take - -1. **Run Phase 0 in each of the three repos and paste back the `/tmp/skills-overhaul.env` output for each.** This resolves the repo-identity question (are `UnityLodUvLightmapTransfer` / `lightmap-uv-tool` the same as `UnityMeshLab`?) and gives the plan the parameter values it needs. Estimated time: 3 minutes per repo. -2. **Decide the two policy questions in §5.2**: (a) namespace Stance A vs B and (b) whether to include directive "ALWAYS invoke" phrasing on the three most-critical skills. These decisions change only a handful of lines in `_shared/naming-conventions.md` and in three frontmatter descriptions. -3. **Execute Phase 1 (scaffold) in one repo as a pilot**, then Phase 2.1 (`_shared/` and `_checklists/` files) — these are repo-agnostic and can be copied identically to the other two repos once validated. Commit per the sub-commit plan. Only after that pilot runs cleanly through Phase 4 validation do you replicate to the remaining repos. \ No newline at end of file diff --git a/.claude/skills/unity-package-architect/SKILL.md b/.claude/skills/unity-package-architect/SKILL.md index 2fb6701b..a729ff51 100644 --- a/.claude/skills/unity-package-architect/SKILL.md +++ b/.claude/skills/unity-package-architect/SKILL.md @@ -164,7 +164,7 @@ Example: gate code on the presence of the FBX exporter package. ```json "versionDefines": [ - { "name": "com.unity.formats.fbx", "expression": "[5.0.0,6.0.0)", "define": "LIGHTMAP_UV_TOOL_FBX_EXPORTER" } + { "name": "com.unity.formats.fbx", "expression": "[5.0.0,6.0.0)", "define": "UNITY_MESH_LAB_FBX_EXPORTER" } ] ``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b482daee..bf25fa97 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,7 +13,7 @@ Editor/ @SashaRX # Native plugins (binary + source) Plugins/ @SashaRX -Native/ @SashaRX +Native~/ @SashaRX # Shaders Shaders/ @SashaRX diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3c1a5cc..b810224e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,7 @@ - [ ] `Editor/` — Editor tools / UI -- [ ] `Plugins/` / `Native/` — Native plugins +- [ ] `Plugins/` / `Native~/` — Native plugins - [ ] `Shaders/` — Compute / render shaders - [ ] `package.json` — Version / dependencies - [ ] `.github/` — CI / workflows @@ -19,7 +19,7 @@ - [ ] No Editor ↔ Runtime dependency leaks - [ ] Undo support for all scene modifications - [ ] Temporary meshes cleaned up -- [ ] `#if LIGHTMAP_UV_TOOL_FBX_EXPORTER` guards on FBX code +- [ ] `#if UNITY_MESH_LAB_FBX_EXPORTER` guards on FBX code - [ ] CHANGELOG.md updated (if user-visible change) ## Test Plan diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index fb080dea..a6178e62 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -7,7 +7,7 @@ on: paths: - 'Editor/**' - 'Plugins/**' - - 'Native/src/**' + - 'Native~/**' jobs: bump: diff --git a/.gitignore b/.gitignore index db5d7d20..56299e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ Thumbs.db # CMake build output (xatlas fetched automatically) Native~/_deps/ +# Local agent/session state and generated experiment output +.remember/ +_results~/ + # Test file test.txt diff --git a/.npmignore b/.npmignore index 91429044..7cf0d37d 100644 --- a/.npmignore +++ b/.npmignore @@ -10,8 +10,7 @@ REVIEW.md.meta .github/ # Native C++ source — users get prebuilt Plugins/ only -Native/ -Native.meta +Native~/ # Dev/build files CONFLICT_CHECK_LAST_5_PRS.md diff --git a/AGENTS.md b/AGENTS.md index 3f41d57d..91ab42df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,21 +16,21 @@ The canonical rule set lives in `.claude/skills/`. This file is the top-level po | Zone | Purpose | Sensitivity | |------|---------|-------------| | `Editor/` | Editor-only tools | High — asmdef, Undo, lifecycle | -| `Plugins/` | Native binaries (xatlas, V-HACD, meshoptimizer) | Critical — must match `Native/` source | -| `Native/` | C/C++ source for native plugins | Critical — changes trigger CI rebuild | +| `Plugins/` | Native binaries (xatlas, V-HACD, meshoptimizer) | Critical — must match `Native~/` source | +| `Native~/` | C/C++ source for native plugins | Critical — changes trigger CI rebuild | | `Shaders/` | Compute/render shaders for GPU tools | Medium — platform compatibility | | `package.json` | UPM manifest | High — version, dependencies | ## Domain-specific hard rules (not covered by skills) -- **Native plugins**: never modify `Plugins/*.dll|*.so|*.dylib|*.bundle` directly — rebuild from `Native/` source via the `build-native.yml` CI workflow. -- **Transfer pipeline experiments**: read `EXPERIMENTS.md` before modifying `GroupedShellTransfer`, `XatlasRepack`, or `SymmetrySplitShells`. One experiment per PR, documented in `EXPERIMENTS.md`. +- **Native plugins**: never modify `Plugins/*.dll|*.so|*.dylib|*.bundle` directly — rebuild from `Native~/` source via the `build-native.yml` CI workflow. +- **Transfer pipeline experiments**: read `Documentation~/EXPERIMENTS.md` before modifying `GroupedShellTransfer`, `XatlasRepack`, or `SymmetrySplitShells`. One experiment per PR, documented in `Documentation~/EXPERIMENTS.md`. - **LODGroup lifecycle**: call `RestoreWorkingMeshes()` before clearing or switching LODGroup context. - **LOD / collision naming**: `Name_LOD{N}` (e.g., `Chair_LOD0`), `Name_COL` or `Name_COL_Hull{N}`. Group key extracted via `UvToolContext.ExtractGroupKey()`. - **Sidecar assets**: `ModelName_uv2data.asset` — persists UV2/collision data alongside FBX. -- **FBX exporter**: code gated by `#if LIGHTMAP_UV_TOOL_FBX_EXPORTER`. +- **FBX exporter**: code gated by `#if UNITY_MESH_LAB_FBX_EXPORTER`. - **Regex in `LightmapTransferTool.cs`**: use fully-qualified `System.Text.RegularExpressions.Regex` — no top-level `using`. -- **Logging**: `UvtLog.Info` / `UvtLog.Warn` / `UvtLog.Error` (prefix `[LightmapUV]`). +- **Logging**: `UvtLog.Info` / `UvtLog.Warn` / `UvtLog.Error` (prefix `[MeshLab]`). For mutation safety, package structure, serialization, CI, and release mechanics — consult the relevant skill in `.claude/skills/`, not this file. diff --git a/CHANGELOG.md b/CHANGELOG.md index f03ed638..18645ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] ### Added +- **Per-target Transfer diagnostic summary** — `GroupedShellTransfer.TransferCore` emits a single Info line at the end of every transfer when `UvtLog.Category.TransferDiag` is enabled in the Log filters panel. Reports source/target shell counts, `shellsMatched` / `shellsRejected`, `ShellStatus` histogram (Accepted/Degraded/Poor/Rejected/Unmatched), method histogram (interp/xform/merged), `fragmentsMerged`, `dedupConflicts`, `shellsOverlapFixed`, `consistencyCorrected`, mean/max 3D match distance, and topology iterations/fixed/capHit. Lets the identity-sanity and per-LOD ratio sweep checklists in `Documentation~/TRANSFER_LOD_QUALITY_PLAN.md` be run without trawling verbose per-shell output. New category bit slots into the existing Log filters UI automatically. - **`UvProgress` service** (`Editor/Framework/UvProgress.cs`) — central non-modal progress reporting. Routes status to `UnityEditor.Progress` (Background Tasks panel) plus an inline strip drawn at the bottom of the hub window. Supports nested scopes, phase labels, indeterminate/determinate fractions, cooperative cancellation via `UvProgress.CancelRequested` (`Volatile.Read`-backed `_cancelFlag` so background `Task.Run` work observes user-cancel reliably across the memory barrier), a thread-safe `ReportFromBackground` for `Task.Run` callers (with `Interlocked.Exchange`-guarded snapshot/clear so a racing writer can't lose an update; the `EditorApplication.update` pump is hooked once on assembly load from the main thread via `[InitializeOnLoadMethod]`), and a `Last` outcome shown while idle. - **Inline progress strip in `UvToolHub`** — sits at the bottom of the window as a status bar. Reserves fixed height unconditionally so toggling active state doesn't displace any layout. Shows title · phase · detail · elapsed in distinct columns with a Cancel button pinned to the right; while idle displays `✓ / ✗ Last-operation · 12.3s`. Marquee animation for indeterminate fractions; orange tint while cancelling. - **Async pipeline (no main-thread freeze)**: @@ -61,9 +62,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [1.0.0] - 2026-04-20 ### Changed (breaking) -- **Package identifier** renamed from `com.sasharx.lightmap-uv-tool` to `com.sasharx.unitymeshlab` to align with the repository name and the canonical `com.sasharx.` rule in `.claude/skills/_shared/naming-conventions.md`. Downstream consumers must update the entry in `Packages/manifest.json`. -- **Root namespace** migrated from `LightmapUvTool` to `SashaRX.UnityMeshLab` across all 48 Editor C# files. The asmdef `name` and `rootNamespace` were updated and the asmdef file was renamed `LightmapUvTool.Editor.asmdef` → `SashaRX.UnityMeshLab.Editor.asmdef` (GUID preserved). External code that referenced the bare `LightmapUvTool` namespace must switch to `SashaRX.UnityMeshLab`. -- **`repository.url`** updated from `UnityLodUvLightmapTransfer.git` to `UnityMeshLab.git` to match the current canonical GitHub URL. +- **Package identifier** standardized as `com.sasharx.unitymeshlab` to align with the repository name and the canonical `com.sasharx.` rule in `.claude/skills/_shared/naming-conventions.md`. Downstream consumers must update the entry in `Packages/manifest.json`. +- **Root namespace** standardized as `SashaRX.UnityMeshLab` across all Editor C# files. The asmdef `name`, `rootNamespace`, and filename were updated with the GUID preserved. +- **`repository.url`** standardized as `UnityMeshLab.git` to match the canonical GitHub URL. ### Added - `documentationUrl`, `changelogUrl`, and `licensesUrl` fields in `package.json`. @@ -75,13 +76,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ```json "com.sasharx.unitymeshlab": "https://github.com/SashaRX/UnityMeshLab.git" ``` -- In any code that referenced the `LightmapUvTool` namespace, switch to `SashaRX.UnityMeshLab`. +- In downstream code, use the canonical `SashaRX.UnityMeshLab` namespace. ## [0.15.36] - 2026-04-07 ### Added — Cleanup tool (#59) - **New "Cleanup" tab** (ToolOrder 45, between Collision and Vertex AO) for mesh/material/collider/scene hygiene -- **Fix Materials**: detect Hidden_LightmapUvTool shaders on renderers, match LOD1+ materials to LOD0, strip unused material slots +- **Fix Materials**: detect hidden UnityMeshLab shaders on renderers, match LOD1+ materials to LOD0, strip unused material slots - **Clean Colliders**: strip UV/normal/tangent/color from _COL meshes, detect and remove duplicate _COL objects - **Scene Cleanup**: remove orphaned LOD objects after FBX reimport, rebuild LODGroup from hierarchy naming - **Mesh**: batch weld via meshopt, strip empty UV channels, per-LOD vertex/tri count summary @@ -106,7 +107,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this #### Vertex AO Baking - **GPU vertex AO baker**: hemisphere depth sampling via compute shader - **CPU fallback**: for platforms without compute shader support -- **Depth shader**: `Hidden/LightmapUvTool/VertexAODepth` for orthographic depth rendering +- **Depth shader**: `Hidden/UnityMeshLab/VertexAODepth` for orthographic depth rendering - **Compute shader**: `VertexAOAccum.compute` with AccumulateAO and FinalizeAO kernels #### Safety & Lifecycle diff --git a/CLAUDE.md b/CLAUDE.md index 6214243c..0814bdfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ See `AGENTS.md` for shared rules that apply to all AI agents. 4. Do NOT bulk-rename/reformat unless explicitly asked 5. Verify compile locally before proposing PR 6. Split large tasks into small PRs -7. Before experimenting with transfer pipeline — read `EXPERIMENTS.md` +7. Before experimenting with transfer pipeline — read `Documentation~/EXPERIMENTS.md` ## Code Rules @@ -21,8 +21,8 @@ See `AGENTS.md` for shared rules that apply to all AI agents. - No `using System.Text.RegularExpressions` in `LightmapTransferTool.cs` — use fully qualified `System.Text.RegularExpressions.Regex` - `internal` visibility for cross-tool helpers (same assembly) - `Undo.RecordObject` / `Undo.AddComponent` / `Undo.DestroyObjectImmediate` for all scene modifications -- Logging via `UvtLog.Info()` / `UvtLog.Warn()` / `UvtLog.Error()` (prefixed `[LightmapUV]`) -- FBX code gated by `#if LIGHTMAP_UV_TOOL_FBX_EXPORTER` +- Logging via `UvtLog.Info()` / `UvtLog.Warn()` / `UvtLog.Error()` (prefixed `[MeshLab]`) +- FBX code gated by `#if UNITY_MESH_LAB_FBX_EXPORTER` - `RestoreWorkingMeshes()` before clearing/switching LODGroup context - Destroy temporary meshes (repacked, transferred, welded) when no longer needed @@ -31,7 +31,7 @@ See `AGENTS.md` for shared rules that apply to all AI agents. - **Entry point:** `Editor/Framework/UvToolHub.cs` — main EditorWindow - **Context:** `Editor/Framework/UvToolContext.cs` — shared state - **Tools:** `Editor/Tools/` — each implements `IUvTool` -- **Native:** `Plugins/` binaries, `Native/` C++ source +- **Native:** `Plugins/` binaries, `Native~/` C++ source - **Sidecar:** `Uv2DataAsset` persists UV2/collision data alongside FBX ## Key Patterns diff --git a/Documentation~/EMBREE_INTEGRATION_PLAN.md b/Documentation~/EMBREE_INTEGRATION_PLAN.md index d80ec278..0c22b226 100644 --- a/Documentation~/EMBREE_INTEGRATION_PLAN.md +++ b/Documentation~/EMBREE_INTEGRATION_PLAN.md @@ -101,7 +101,7 @@ Extend `Editor/PostprocessorDefineManager.cs`: or `Plugins/x86_64/umlab-embree.so` (Linux). - If present: add `UNITYMESHLAB_EMBREE` to `PlayerSettings` scripting defines (Editor platform group). If absent: remove it. -- Mirrors the existing pattern for `LIGHTMAP_UV_TOOL_FBX_EXPORTER`. +- Mirrors the existing pattern for `UNITY_MESH_LAB_FBX_EXPORTER`. ## C ABI surface diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 54ed2b86..795a6d2f 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -351,3 +351,176 @@ - EditMode red/green: `TransferTargetDetection_IgnoresSourceOnlySelection`. - EditMode red/green: `Uv2PixelMargin_ScalesFromResolvedAtlasSize`. - Full model benchmark (Carousel/Playground/WateringCan) в этом checkout не прогнан: тестовые FBX/`BenchmarkReports/` отсутствуют в репозитории. Нужен ручной Unity прогон на suite для финального сравнения `repackMs`, `density spread`, `overlapShellPairs`, `invertedCount`, `texelDensityBadCount`. + +## Эксперимент 2026-06-03 — Stage D cascade-threshold sweep (4 кейса × 9 ячеек) + +**Контекст:** Plan v2 Stage D (cascade group matching deep→fine) родил пороги `cascadeMatchFrac` × `cascadeMinHits`. Чтобы найти knee, прогнали sweep `{0.35,0.50,0.65} × {2,4,8}` на 4 моделях (Gazebo/Carousel/Playground/WoodenBox01). Артефакты — `bench_2026-06-03_00-01-29-061/{case}/hier/stage_d_sweep.csv` + `lod{N}_groups_mf*_mh*.png`. + +**Что подтвердилось:** +- Каскадная идея валидна: крупные lighting-домены держат цвет (= один groupId) через все LOD'ы. WoodenBox: пол/задняя стена/правая стена/рама стабильны LOD3→LOD0. Carousel: 6 пирогов канопе и скамьи идентичны LOD2→LOD0 даже при final groupCount=922. +- `missed=0` на каждом переходе во всех кейсах. Проекция здорова, `overlayDistNorm=0.03` не зажат. +- Каскад идёт deepest-first, `reused=0` — родитель всегда резолвится до того, как finer спросит про него. + +**Что НЕ работает — главные находки:** +1. **Пороги почти не влияют на качество доменов.** Визуальная сетка 3×3 на Carousel LOD0 идентична на крупных поверхностях во всех 9 ячейках. Разница 808↔1198 групп — целиком в мелких шеллах (тонкая рама, проволока, кромка). Тюнить mf/mh ради качества доменов смысла мало. +2. **Взрыв групп идёт НЕ от плохого матчинга, а от пролиферации микро-шеллов в Stage C.** Carousel LOD0 = 773 raw shells ≈ 6 крупных кусков канопе + ~767 тонких деталей. Поэтому 269→922, Playground 318→1664. И `skipAreaFrac`/`skipMaxFaceCount` тогда были объявлены в `Options.Default`, но **нигде не применялись**. +3. **`minHits` работал контр-продуктивно.** Это был доминирующий рычаг (Carousel LOD1→0 fresh при mh=2/4/8: 421/467/618). Логика «мало хитов → не доверяем голосу → fresh» наказывала именно те крошечные шеллы, которым отдельный домен нужен в последнюю очередь. +4. **`groupCount` — обманчивая метрика.** Она зависит от шума микро-шеллов, не от качества доменов. Минимизировать её = минимизировать фрагментацию проволоки. + +**Изменение:** Stage D получил **tiny-shell merge ветку** (commit на бранче `claude/fix-transfer-bugs-KYVQD`): +- finer shell с `totalArea ≤ opts.skipAreaFrac × totalFineArea` И `faceCount ≤ opts.skipMaxFaceCount` force-join'ится на доминантного родителя независимо от matchedFrac/minHits, если bestProxy резолвится. +- Tiny shell без матча (`bestProxy<0` — деталь которой действительно нет на deeper LOD) всё ещё открывает fresh, но засчитывается в отдельный счётчик `tinyOrphan` для видимости. Topological-neighbour fallback для этого кейса не делаем — отложен до данных, показывающих что он нужен. +- `CascadeStat` расширен: `tinyJoined`, `tinyOrphan`. CSV свипа добавляет 2 колонки. Лог Stage D печатает их. + +**НЕ сделано / открытые вопросы:** +- Scored auto-winner свипа не строим: до Stage E (final pack + per-LOD UV2 + lightmap-defect счётчики трансфера) нет объективного скаляра для оптимизации `(matchFrac, minHits, skipAreaFrac, skipMaxFaceCount)`. Свип остаётся сравнительным (PNG + CSV под глаза). +- Не нормализовали пороги по `meshDiag`: knee может смещаться между кейсами. Подтверждено в свипе для Playground (LOD3→2 join только 25% против 75% на LOD1→0) — но это геометрическая реальность (LOD3 беднее деталью), не порог. +- Default `skipAreaFrac=0.001`/`skipMaxFaceCount=4` оставлен как есть — ожидаем повторного свипа после tiny-merge, теперь уже по `tinyJoined`/`tinyOrphan`, чтобы калибровать. + +**Проверка:** +- Свип-прогон 4 кейса × 9 ячеек завершился без ошибок, артефакты на месте. +- Повторный свип после tiny-merge ещё не делался — TODO следующим шагом, на тех же 4 кейсах, чтобы померить просадку `fresh` и убедиться что `tinyOrphan` мал. + +## Эксперимент 2026-06-03 (продолжение) — повторный свип ПОСЛЕ tiny-merge + +**Артефакты:** `bench_2026-06-03_01-02-41-045/{case}/hier/stage_d_sweep.csv` (+ per-cell PNG; это прогон до suppress-фикса). Все 4 CSV содержат заполненные `tinyJoined`/`tinyOrphan` → tiny-merge активен. + +**Результат на default-ячейке (mf0.50 / mh4), fresh суммирован по переходам:** + +| Кейс | seed | groupCount | fresh | tinyJoined | tinyOrphan | orph % от fresh | +|---|---|---|---|---|---|---| +| Gazebo | 138 | 222 | 84 | 59 | 61 | 73% | +| Carousel | 269 | 818 | 549 | 104 | 468 | **85%** | +| Playground | 318 | 1255 | 937 | 409 | 684 | 73% | +| WoodenBox01 | 6 | 105 | 99 | 66 | 97 | **98%** | + +**Что подтвердилось:** +1. **tiny-merge делает свою работу.** `tinyJoined` поглощает штраф minHits: 59/104/409/66 шеллов, которые иначе ушли бы в fresh, теперь подсасываются к родителю. groupCount просел против pre-merge свипа (Carousel 922→818, Playground 1664→1255, Gazebo 281→222, WoodenBox 171→105). +2. **Каскад по-прежнему держит крупные домены.** Визуал `lod0_groups.png`: Carousel — 6 чистых секторов канопе + когерентные скамьи; WoodenBox — пол/стены схлопнулись в 3 цвета. `missed=0` везде. + +**Что НЕ работает — решающая находка:** +3. **73–98% всех fresh-групп = `tinyOrphan`** — крошечные шеллы (area ≤ 0.001×total И faceCount ≤ 4) у которых `bestProxy < 0`, т.е. на deeper LOD геометрии под них вообще нет. tiny-merge их не трогает (force-join требует резолвящегося родителя), поэтому каждый открывает свой lighting-домен. Это и есть оставшийся источник взрыва групп — тонкий обод, трубки ножек, кромки рамы (визуально: разноцветное крошево по ободу Carousel и по рамам WoodenBox). +4. **Порог тут бессилен — это доказано данными, не на глаз.** `tinyOrphan` на финишном переходе LOD→0 имеет РОВНО ОДНО значение через все 9 ячеек: Gazebo=19, Carousel=378, Playground=83, WoodenBox=86. Он threshold-invariant by construction (`bestProxy<0`), поэтому никакой mf/mh не уберёт доминирующую часть взрыва. +5. **WoodenBox — чистейшая демонстрация:** seed всего 6 групп, финал 105, и 97 из 99 fresh — tinyOrphan. Весь его group-count это orphan-крошево на LOD0. + +**Вывод / следующий шаг (ранее отложенный — теперь данные требуют его):** нужен **topological-neighbour fallback** для tinyOrphan. Раньше в коде стоял комментарий «defer until the data shows it's needed» — данные показали: на финишных LOD'ах orphan'ы дают 85–98% fresh. План: построить shell-shell adjacency на finer LOD (по общим рёбрам `canonicalTris`), и tinyOrphan вливать в соседний шелл с наибольшей общей границей, наследуя его groupId, вместо открытия fresh-группы. Открытый вопрос — что делать с orphan'ом, у которого ВСЕ соседи тоже tinyOrphan (цепочка крошечных шеллов): либо chain-resolve до первого не-tiny, либо слить такой кластер в один общий домен. + +**НЕ сделано:** +- Topological fallback ещё не реализован — отложен (см. ниже: сначала Stage E даёт объективную метрику). +- Defaults `skipAreaFrac=0.001`/`skipMaxFaceCount=4` не трогали; калибровать уже по метрикам Stage E, а не по `tinyOrphan`. + +## Эксперимент 2026-06-03 (Stage E) — старт пакинга домен-атласа, слайс E1 + +**Решение:** вместо tinyOrphan-fallback идём в Stage E. Обоснование: `groupCount`/`tinyOrphan` — это 3D-сегментация без единой UV-координаты, объективной метрики нет. Только Stage E даёт 2D-атлас → overlap/density/inverted. И вероятно orphan-крошево в атласе займёт пренебрежимо мало — решим это уже по метрикам. + +**Нарезка (один концерн = один коммит):** +- **E1 (этот коммит):** `PackDomainCharts` — для каждой lighting-группы её canonical-шелл проецируется планарно (`basisU/V`, `extentU/V` из Stage C) в локальный [0,1], скармливается в `xatlasAddUvMesh` с `faceMaterial = groupId` (границы чартов по группам), `ComputeCharts`+`PackCharts` раскладывают. Выход: `r.domainAtlasRects[groupId]` (общий layout домена) + `domains_atlas.png`. Без записи мешей. +- **E2:** для каждого LOD member-шеллы проецируются в rect своей группы → `finalUv2/finalTris/finalSourceVertexIdx` → `BuildFinalMeshes` (Stage F готов) → `lodN_final_uv2.png`. +- **E3:** метрики на атласе (overlap-пары / inverted / texel density) → CSV + скаляр для пере-свипа порогов. + +**Технические находки при реализации E1:** +- `xatlasAddUvMesh → ComputeCharts → PackCharts` — рабочая последовательность в этой кодовой базе (так делает `XatlasRepack.RepackSingle`); `ComputeCharts` на UV-меше НЕ пере-развёртывает, держит наши UV, а `faceMaterial` задаёт границы чартов. +- Выход `xatlasGetOutputVertexData` уже нормирован в [0,1] в этой нативной сборке — проверено по `proxy_uv2_auto.png` (читает выход напрямую, заполняет 0-1 box). Комментарий «atlas-pixel space» в `XatlasRepack.cs` устарел. + +**Проверка:** Unity-компиляция в этом окружении недоступна (нет toolchain'а; тесты/FBX/бенч — ручной прогон). Сделана статическая сверка сигнатур (`xatlasAddUvMesh`/`PackCharts`/`GetOutputVertexData`), полей `Shell3D`/`Options`, баланс скобок. Прогон бенча на 4 кейсах + визуальная проверка `domains_atlas.png` — следующий шаг (ручной, в Unity). + +### Результаты E1 (bench_2026-06-03_01-48-52-098, 4 кейса) + +`domains_atlas.png` сгенерился во всех кейсах, Stage E отработал. + +**Что ✅ работает:** +- Пакинг корректен на всех 4 кейсах: UV в [0,1], атлас плотно заполнен, без грубых меж-чартовых наложений. Data-model (groups → canonical charts → packed rects) валидирован end-to-end. +- Крупные lighting-домены = аккуратные прямоугольные чарты (Gazebo — почти идеальная сетка квадов; WoodenBox панели; Carousel секторы канопе; Playground платформы). +- Orphan-крошево, хоть его сотни (Carousel ~468), индивидуально занимает крошечную площадь — крупные домены доминируют по площади атласа. Это подтверждает, что отложить tinyOrphan-fallback было правильно: в атласе оно дешёвое. + +**Что ⚠️ вскрылось — главная находка E1: фолдинг кривых шеллов.** +- Шеллы, которые **заворачиваются** (цилиндры, кольца, трубы, дуги: обод Carousel, балясины/арки Gazebo, трубы горок Playground), при планарной проекции на ОДНУ плоскость складываются сами в себя. Визуально: синусоиды (∿∿∿), плотная радужная вертикальная штриховка, «бабочки»-песочные часы, круглые розетки, C-образные завитки. +- **Корень:** `dominantNormal` = нормируемая area-weighted сумма нормалей граней (`ExtractShells`, ~813). У заворачивающегося шелла противоположные нормали взаимогасятся → `dominantNormal ≈ 0` → `ComputePlaneBasis` даёт мусорный базис → проекция вырождается/складывается. Такой чарт = внутренние наложения UV → запёкся бы мусорный лайтмап. +- **Доля невелика** и это тонкий кривой trim (по площади ≪ плоских доменов): Gazebo ~5-6 дефектных из ~280, Carousel — весь обод (заметно больше), Playground — трубы/перекладины по краю. Многие из них — те же tinyOrphan. +- **Метрика детекции (дешёвая, на этапе extraction):** `coherence = |accumNormal| / totalArea` — ≈1 для плоского шелла, →0 для заворачивающегося. Порог отделяет developable-патчи от закрученных. + +## Эксперимент 2026-06-03 (Stage E2) — собственно КАСКАД: проекция LOD'ов в rect домена + +**Разбор ошибки.** E1 и попытка «стичить UV0» строили Stage E как «развернуть+упаковать каждый canonical-шелл по отдельности» и спорили про параметризацию (planar vs UV0 vs фолдинг). Это **игнорировало весь смысл каскада**. Каскад — про КРОСС-LOD согласованность: +- canonical-шелл группы = самый глубокий член = тот, что **не нашёл мэтч** → **перепаковывается** (получает rect в атласе); +- **все остальные члены на всех LOD проецируются в ТОТ ЖЕ rect** (basis/centroid/extent canonical'а) → один лайтмап валиден через все LOD; +- шелл, не нашедший мэтч на своём LOD → canonical своей fresh-группы → перепакован в свой слот. + +UV0/фолдинг — побочный вопрос; параметризация canonical'а вторична, главное — проекция членов в общий rect. + +**Изменение (slice E2):** +- Откатил UV0 в `PackDomainCharts` — canonical снова планарный. `rotateCharts:0` ⇒ placed rect = входной [0,1] box, масштабированный/сдвинутый, значит проекцию можно воспроизвести линейным `[0,1]→rect`, и члены лягут в совпадение с canonical'ом. +- Новый `BuildCascadedUv2`: для КАЖДОГО шелла КАЖДОГО LOD проекция верт на плоскость canonical'а его группы → local [0,1] → в `domainAtlasRects[gid]`. Пишет `finalUv2/finalTris/finalSourceVertexIdx` per LOD (без дедупа, по 3 верта на грань — Stage F копирует атрибуты по `srcIdx`). +- `lod{N}_final_uv2.png` — диагностика: один домен должен занимать одну область атласа на всех LOD. + +**Проверка:** Unity-компиляция недоступна (нет toolchain'а), статическая сверка типов/сигнатур/скобок. Прогон бенча + `lod{N}_final_uv2.png` (проверить совпадение области домена через LOD) — следующий ручной шаг. + +**Открыто:** фолдинг кривых canonical'ов даёт искажённую (но кросс-LOD согласованную) проекцию; barycentric-pull на меше canonical'а вместо планарной плоскости — потенциальное улучшение качества, не блокер каскада. + +### Результаты E2 (bench_2026-06-03_02-59-26-442) — каскад ПОДТВЕРЖДЁН ✅ + +`lod{N}_final_uv2.png` сгенерились на всех LOD всех кейсов. Главная проверка — кросс-LOD согласованность — пройдена. + +- **WoodenBox LOD3→LOD2→LOD0:** крупные домены (пол, стены) стоят в ОДНОЙ И ТОЙ ЖЕ области атласа на всех LOD. LOD3 = ~6 чартов в конкретных rect'ах; LOD2 = те же rect'ы, дробятся на больше суб-чартов; LOD0 = те же rect'ы заняты + сотни мелких чартов трима по остатку атласа. Домен заякорен на месте, детализация растёт внутри/вокруг — by construction (шелл группы `g` на любом LOD → `domainAtlasRects[g]`), и визуал подтверждает что багов нет. +- Один лайтмап, запечённый для домена, теперь валиден через все LOD — основная цель каскада достигнута. + +**Остаточный дефект (известный, не блокер):** кривые/вырожденные canonical-шеллы дают «Union Jack» чарты с белой штриховкой (внутренние наложения от планарного фолдинга). Согласованы через LOD (одинаковое искажение), но внутри чарта UV перекрываются → этот домен запечётся с артефактом. Кандидаты на фикс: barycentric-pull на меше canonical'а, либо splitting кривых шеллов на developable-патчи. + +**Следующее:** либо (E3) метрики на атласе (overlap/density/inverted) для объективной оценки, либо фикс фолдинга кривых canonical'ов. Каскадный костяк готов. + +## Эксперимент 2026-06-03 (Stage E fix) — убрана дисторшн-нормировка, единый тексель + +**Разбор ошибки (критичной).** Я нормировал каждый шелл в свой `[0,1]` по его `extentU/extentV`. Это (а) **искажало** непрямоугольные шеллы (длинный тонкий → растянут в квадрат) и (б) **убивало тексель-density**: огромная стена и крошечный винт оба → `[0,1]`, т.е. у мелкого текселей на единицу площади в сотни раз больше. Для лайтмапа недопустимо. + +**Рецепт (от пользователя): классика → выровнять UV → идентичный тексель.** Реализовано: +- **Реальные пропорции:** canonical-шелл проецируется на свою плоскость в МИРОВЫХ единицах (`inU=dot(d,basisU)`, `inV=dot(d,basisV)`), БЕЗ нормировки-в-квадрат. +- **Идентичный тексель:** xatlas пакует с ФИКСИРОВАННЫМ `texelsPerUnit` (= `atlasRes·√(packEff/totalCanonArea)`), а не auto-fit — все чарты одной плотности. +- **Выравнивание (affine):** из placed-UV canonical'а LSQ-фитом снимается аффинное `inU→atlasU, inV→atlasV` → `r.domainPlacements[gid]`. Canonical воспроизводит своё размещение, а ВСЕ члены группы на всех LOD используют ТОТ ЖЕ affine → лежат в той же области атласа с той же плотностью. Заменило `[0,1]→rect` нормировку. + +**Остаётся:** «классика» пока = планарная проекция, что для ПЛОСКИХ шеллов точно (плоскость разворачивается тривиально), но кривые canonical'ы (обод/трубы) всё ещё складываются. Полный фикс — xatlas classical unwrap кривых шеллов (Stage B `fineClassicalUv2` уже считает классику per-LOD) вместо планара. Дисторшн+тексель для плоских доменов теперь корректны. + +**Проверка:** Unity-компиляция недоступна, статическая сверка типов/LSQ/единиц/скобок. Прогон бенча — следующий шаг. + +## Эксперимент 2026-06-03 (Stage E fix 2) — СОХРАНЯТЬ shell, не перепроецировать + +**Разбор (ещё одна моя ошибка).** Даже с реальными пропорциями я всё равно ВЫВОДИЛ UV заново через `dot(d,basis)` (планарная проекция) — т.е. уничтожал исходную развёртку шелла и складывал кривые. Правильно: **сохранять shell** — взять родную UV0 шелла и **проецировать/размещать** её, ничего не перепридумывая. + +**Реализация:** +- В `PackDomainCharts` вход xatlas = **сохранённая UV0** canonical-шелла, только recenter (вычесть центроид острова) + единый масштаб `S = √(area3D/areaUV0)` (форма не трогается, лишь нормируется тексель-density: UV-площадь → 3D-площадь). Фикс `texelsPerUnit` → идентичный тексель по всем доменам. +- Affine `scaled-UV0 → atlas` снимается LSQ; в `domainPlacements[gid]` теперь `{uvc, scale, su,ou,sv,ov}`. +- В `BuildCascadedUv2` каждый шелл берёт **свою UV0**, применяет placement своей группы: `in=(uv0−uvc)·scale; uv=affine(in)`. LOD'ы домена делят UV0-раскладку → одинаковый UV0 → одинаковый тексель атласа на всех LOD. Развёртка шелла сохранена, без планарной перепроекции. + +Планарный `dot(d,basis)` полностью убран из Stage E. Допущение: LOD'ы делят UV0-layout одного домена (стандартная практика). Если нет — выравнивание матчей сломается, увидим на `lod{N}_final_uv2.png`. + +**Проверка:** Unity-компиляция недоступна, статическая сверка типов/границ/скобок (защита от рассинхрона faceMat↔индексы и обрезанных треугольников). Прогон — следующий шаг. + +### Результаты preserve-UV0 (bench_2026-06-03_08-41-51-481) — ✅ всё сошлось + +Скомпилировалось (после фикса CS0136 `fc`). Картинки по 4 кейсам: +- **Фолдинг ушёл.** `domains_atlas.png`: чарты — реальные UV0-острова с настоящими пропорциями (длинные тонкие = рейки/балясины беседки, веера = секторы канопе Carousel, квадраты = панели). Синусоид/бабочек/Union-Jack больше нет. +- **Кросс-LOD согласованность держится.** WoodenBox LOD3 = 6 крупных доменов блоком 2×3 внизу-слева; LOD0 = ТЕ ЖЕ квадраты в тех же местах + тонкий трим вокруг. Один домен → одна область атласа на всех LOD. +- **Развёртка сохранена** (UV0, не перепроецирована), **тексель единый** (S-нормировка + фикс texelsPerUnit). + +Итог: рецепт «сохранять shell → проецировать → выровнять → идентичный тексель» реализован и подтверждён визуально. + +**Остаточные мелочи (не блокеры):** packing ~50% (packEff=0.5, можно поднять); тонкий трим — очень тонкие полоски (реальная геометрия); Carousel плотный/шумный (сотни тонких деталей). Допущение про общий UV0-layout LOD'ов подтвердилось на тест-сьюте (домены сошлись). + +## Эксперимент 2026-06-11 — Stage E3 метрики + устойчивость placement'ов (E2) + +**Цель:** «рабочий надёжный вариант» — у пайплайна не было объективного скаляра качества (всё по PNG на глаз) и было два тихих способа потерять геометрию/раскладку на реальных ассетах. + +**Фикс 1 (E1, LSQ degenerate axis).** Канонический чарт — идеально прямая axis-aligned полоска в UV0 → нулевая дисперсия по одной оси → знаменатель per-axis LSQ ≈ 0 → placement всей группы invalid. Теперь вырожденная ось заимствует масштаб разрешённой оси (xatlas применяет uniform scale, rotateCharts:0, флипа нет); обе оси вырождены (чарт-точка) → фолбэк на расчётный `texelsPerUnit/resolution` с якорем в среднем placed-UV. + +**Фикс 2 (E2, никогда не дропать грани).** Шеллы групп без валидного placement раньше молча пропускались в `BuildCascadedUv2` → дыры в финальных мешах после Apply. Теперь они эмитятся с uv2=(0,0) (плохой бейк на этих гранях, но не потерянный треугольник), считаются (`finalUnplacedFaces`) и логируются Warn'ом. + +**E3 (`ComputeStageEMetrics`)** — растр финального UV2 каждого LOD на разрешении атласа (texel-centre ownership): +- `overlapTexels`/`overlapShellPairs` — тексели, на которые претендуют 2+ треугольника, не являющиеся одной гранью и не seam-adjacent внутри одного шелла (общий source-вертекс или совпадающая UV-вершина в пределах 0.75 текселя). Кросс-шелл конфликты считаются ВСЕГДА: mirror-reuse UV0 между шеллами — ровно тот дефект, который надо ловить (у зеркальных островов все вершины совпадают — нельзя вайтлистить по совпадению вершин). Прислонившиеся острова дают ~1px линии — на порядки меньше площадных настоящих overlap'ов. +- `invertedFaces`/`degenUvFaces`/`oobVerts` — перевёрнутая намотка, нулевая UV-площадь, вершины вне [0,1]. +- `tpuMean/P1/P99/Spread` — area-weighted разброс texels-per-world-unit; рецепт preserve-UV0 + фикс texelsPerUnit должен держать ~1×. +- `xLodContainedPct`/`misalignedGroups` — КОНТРАКТ КАСКАДА ИЗМЕРЯЕТСЯ, А НЕ ПРЕДПОЛАГАЕТСЯ: тексели групп, чей canonical на другом LOD, должны попадать в 3×3-дилатированный футпринт canonical-LOD той же группы. Низкий containment = ассет нарушает допущение «LOD'ы делят UV0-layout» → один бейк НЕ валиден через LOD'ы. Это главный флаг надёжности на произвольных пользовательских моделях. + +**Выходы:** `stage_e_metrics.csv` (строка на LOD) + `lod{N}_overlap.png` (серый — покрыто, красный — overlap) в bench-папке `hier/`; per-LOD строки в логе; диалог Apply показывает хедлайн (overlap px / unplaced faces / misaligned domains). `stage_d_sweep.csv` получил cell-level колонки `e3OverlapTexels/e3OverlapPct/e3TpuSpreadMax/e3XLodMinPct/e3MisalignedGroups/e3UnplacedFaces` — свип порогов Stage D теперь скорируемый (минимизировать e3OverlapTexels при e3XLodMinPct близком к 100). + +**Известные слепые пятна метрики (задокументированы в коде):** одиночный треугольник, сложенный ровно на своего edge-соседа, проходит как adjacent (фолды глубже одного треугольника ловятся через не-смежные пары); же-шелловые острова, разорванные в UV0 на несколько кусков, не конфликтуют (они в разных местах атласа — и это корректно). + +**Проверка:** Unity-компиляция в окружении недоступна; статическая сверка типов/скобок/сигнатур. Следующий ручной шаг — бенч на 4 кейсах: ожидаем `overlapPct` ≈ 0 на WoodenBox/Gazebo, заметный на Carousel если остался mirror-reuse; `tpuSpread` ~1–1.5×; `xLodContainedPct` > 85% на всём сьюте; `unplacedFaces` = 0. diff --git a/Documentation~/HIERARCHICAL_CASCADE_PLAN.md b/Documentation~/HIERARCHICAL_CASCADE_PLAN.md new file mode 100644 index 00000000..31644535 --- /dev/null +++ b/Documentation~/HIERARCHICAL_CASCADE_PLAN.md @@ -0,0 +1,198 @@ +# Hierarchical Cascade Projection Plan (v2 — group-then-final-pack) + +Architecture (оператор, уточнённая): каскад deeper→finer LOD +**устанавливает ГРУППЫ (lighting domains)**, НЕ UV-позиции. Во время +каскада шелы разных LOD'ов группируются по transfer-correspondence. +Рабочий атлас может «расти» по мере добавления новых групп, но это +скретч. **Финальная паковка — ОДНА, в самом конце**, когда всё +смэтчилось, с нашими параметрами (texel density, padding, blockAlign). + +Этот сдвиг (паковка отложена в конец) растворяет две дыры старого +плана: +- «xatlas пакует в свободное место не трогая зафиксированное» — + больше не нужно: финал это обычный fresh-pack всех групп, что xatlas + и делает идеально. +- «каскадный proxy = накопленный uv2» — больше не проблема: каскад + несёт group-labels, не UV. Membership пропагируется транзитивно. + +## Data model + +``` +LightingDomainGroup { + int groupId; + int canonicalLod; // самый ГЛУБОКИЙ LOD имеющий шел в группе + int canonicalShellId; // shell id внутри canonicalLod + List<(int lod, int shellId)> members; // все LOD-шелы одного домена +} +``` +Плюс per-LOD: `shellId → groupId`. + +Канонический шел (deepest member) — владелец параметризации домена. +Его classical-unwrap локальный UV определяет форму чарта. Все finer +члены проецируются НА его геометрию (ortho + barycentric) и наследуют +его placed UV. + +## Final goal + +Каждый LOD получает `mesh.uv2` где: +- Шелы в одной группе → один lighting domain → один чарт атласа, + пиксель-в-пиксель между LOD'ами (через проекцию finer→canonical) +- Шелы которых нет в deeper (окна/винты/трим) → новая группа → свой + чарт +- Texel density единый (один `texelsPerUnit` на финальном паке) +- Атлас вырастает чтобы вместить ВСЕ группы + +## Stage map (v2) + +| # | Stage | Что делает | Diagnostic | Pass criteria | Status | +|---|---|---|---|---|---| +| A | Poisson coverage | `GenerateProxySamples`: убран adaptive median filter | `proxy_samples.png` | Pink dots по ВСЕМ чартам | ✅ `3fb4c01` | +| B | Per-LOD classical unwrap | xatlas (sym-split+ARAP+pack) на каждый non-deepest LOD. Diagnostic + источник shell-форм для финал-пака. **Texel НЕ выравниваем тут — это забота финал-пака.** | `lodN_classical_uv2.png` | Каждый LOD пакуется чисто, без инверсий | ✅ `3bcfdfb` | +| — | **Legacy purge** | Удалён весь PR-2 single-proxy classifier + PR-3 single-proxy projector. -1042 строки. Build() остался только: Stage 1 (proxy unwrap variants) + Stage B (per-LOD classical) + Stage 2 (Poisson) + Stage 3 (sample→fineLOD). Apply menu graceful no-op до Stage E. | — | Brace balance 0, CI зелёный, бенч работает (без `final_uv2.png`) | ✅ `c9948e6` | +| **C** | **Per-LOD 3D shell extract + group seed** ⬅ **СЕЙЧАС** | См. ниже | См. ниже | См. ниже | ⬜ next | +| D | Cascade grouping (deep→fine) | Цикл li = deepest-1 … 0. На каждом шаге: Poisson на LOD[li+1] (immediate deeper), project на LOD[li], per-shell vote за deeper-shell → group. matched-fraction ≥ порог → join группы; иначе → новая группа. **Только membership, НЕ UV.** | `lodN_groups.png` (iso, faces по groupId — ОДИН цвет across LODs = один домен) | Соответствующие шелы разных LOD'ов = один цвет. Unmatched = новые цвета. Никакого noise-разброса | ⬜ | +| E | Final pack + per-LOD uv2 | 1) xatlas pack всех canonical-чартов (геометрия canonical членов) ОДНИМ вызовом, unified texelsPerUnit+padding+blockAlign. 2) Per group: построить target (worldVerts + placed uv2 canonical члена). 3) Finer члены: ortho-project на canonical target → barycentric → uv. 4) Seam-dup по groupId. Записать `finalUv2[li]`. | `lodN_final_uv2.png` (ВСЕ LOD в ОДНОМ атласе), `atlas.png` | Все LOD в общем атласе. Группа = один чарт. Никаких sentinel. Атлас покрывает весь контент | ⬜ | +| F | Apply + Bake | `BuildFinalMeshes`+`Apply` (готовы) на каскадный результат. Bake в Unity. | Apply menu, Ctrl+Z, bake | Bake совпадает между LOD0..LOD3 в shared domain | ⬜ | + +## ⬅ Stage C: что именно сейчас делаем + +**Цель:** На каждом LOD'е независимо извлечь 3D шелы и создать +data-model для каскада. **Никакого матчинга / UV / проекции — только +сегментация + seed.** + +### Code changes + +1. **Структура `LightingDomainGroup`** (новая): + ```csharp + public struct LightingDomainGroup + { + public int groupId; + public int canonicalLod; // самый глубокий LOD-член + public int canonicalShellId; // shell id внутри canonicalLod + public List<(int lod, int shellId)> members; + } + ``` + +2. **Новые поля Result:** + ```csharp + // 3D шелы на каждом LOD (индекс = LOD level) + public Shell3D[][] perLodShells; + // shellId per face per LOD (faces[lod][faceIdx] = shellId или -1 для degen) + public int[][] perLodFaceToShell; + // Группы — на старте только seed от deepest + public LightingDomainGroup[] groups; + // shellId → groupId per LOD; начальное состояние: deepest заполнен, + // остальные LOD'ы = все -1 (Stage D присвоит) + public int[][] perLodShellToGroup; + ``` + +3. **Новый метод `ExtractPerLodShellsAndSeedGroups(LODGroup, Options, Result)`** — + вызывается из `Build()` после Stage 3: + - Цикл по всем LOD'ам: + - `BuildFaceData` + `ExtractShells` с теми же параметрами что + раньше использовал deepest (`opts.shellNormalThresholdDeg`, + `opts.shellMergeAngleDeg`) + - Сохранить `perLodShells[li]` и `perLodFaceToShell[li]` + - Seed groups: для каждой shell на deepest LOD'е → новая + `LightingDomainGroup{ canonicalLod=deepest, canonicalShellId=si, + members=[(deepest, si)] }` + - `perLodShellToGroup[deepest]` = 0..N-1 + - `perLodShellToGroup[li]` = все -1 для не-deepest + +4. **Диагностический PNG `WritePerLodShellsPngs`** — iso view каждого + LOD'а раскрашенный по shellId. Используем тот же isometric проектор + что Stage 3's `WriteProxyHitsPngs` (методы `IsoProject`, + `RasterizeTrianglePx` живы). Палитра — простая hash-based hue. + +5. **Wiring:** добавить вызов после Stage 3 в `Build()`, добавить + `WritePerLodShellsPngs` в `BuildAndWriteForCase`. + +### Diagnostic output + +Per case: `lod0_shells.png`, `lod1_shells.png`, … `lod{deepest}_shells.png` + +Каждый PNG — iso view меша, грани окрашены по shellId. На deepest LOD +ожидаем картинку похожую на `proxy_uv2_active.png` по структуре (тот +же набор больших регионов). На finer LODs ожидаем БОЛЬШЕ шелов +(больше детализации) либо ТО ЖЕ количество (если LOD-меши одинаковые +кроме triangulation). + +### Pass criteria (как тестируем) + +1. **Прогон бенча** на 5 моделях, смотрим новые `lodN_shells.png` per + case. +2. **Каждый LOD имеет вменяемое число шелов:** не 1 гигантский (все + слились) и не 1 на каждый tri (всё фрагментировано). Сверяем + глазами с известной геометрией модели. +3. **Шелы — непрерывные регионы:** один цвет = один связный кусок + меша. Никакой пятнистости / разброса noise. +4. **Deepest LOD's shells ≈ proxy chart structure:** сравнить + `lod{deepest}_shells.png` против `proxy_uv2_active.png` — должны + видеть тот же набор больших регионов (cymsplit может разбить + некоторые шелы в proxy на 2 чарта; это OK). +5. **Console log:** счётчик `[Stage C] LOD{li}: {N} shells, seeded + {M} groups (canonical=deepest)` для каждой модели. + +### Что Stage C **НЕ ДЕЛАЕТ** + +- Не матчит шелы между LOD'ами (это Stage D) +- Не строит UV2 (это Stage E) +- Не апдейтит Apply menu (он остаётся no-op до Stage E) +- Не трогает `finalUv2/Tris/SourceVertexIdx` поля Result + +### После прохождения Stage C + +Идём в **Stage D** — каскадный grouping deep→fine. На каждом шаге: +1. Берём текущий «proxy LOD» (на первой итерации = deepest) +2. Poisson-сэмплим его геометрию (`GenerateProxySamples` уже есть, но + надо его параметризовать — сейчас он сэмплит `proxyUv2` фиксы; в + Stage D возможно надо геометрический Poisson, без UV-зависимости — + решим при реализации D) +3. `ProjectProxySamplesOntoFineLods` (есть) — раздаёт sample-hits на + per-face buckets fine-LOD'а +4. Для каждой fine-shell: tally proxy-shell votes, matched-fraction ≥ + порог → join родительскую группу; иначе → новая группа с + `canonicalLod=fine_LOD_id` +5. Diagnostic `lodN_groups.png` — ВЫРОВНЕННАЯ палитра across LODs: + одна группа = один цвет на всех LOD'ах где она присутствует. Это + визуально подтверждает что cascade сошёлся. + +--- + +## Per-stage test protocol + +1. **Push** → CI зелёный (.meta files, compile) +2. **Bench run** на 5 моделях +3. **Inspect** PNG из таблицы +4. **Verdict:** + - ✅ pass → next | ❌ артефакт → описать что вижу → стоп до фикса + +Никаких «продолжаю в следующий стейдж пока этот сломан». + +## Current state + +- ✅ A — Poisson coverage (`3fb4c01`) +- ✅ B — Per-LOD classical unwrap (`3bcfdfb`) +- ✅ Legacy purge (`c9948e6`) — -1042 строки +- ⬜ **C — Per-LOD 3D shell extract + group seed** ⬅ **СЕЙЧАС** +- ⬜ D — Cascade grouping +- ⬜ E — Final pack + per-LOD uv2 +- ⬜ F — Apply + bake + +## Resolved gaps (vs v1) + +- **Дыра 1 texel:** решается на финал-паке (один texelsPerUnit на все группы). Stage B classical остаётся auto (диагностика). +- **Дыра 2 index spaces:** канон = original-mesh face space. Classical (b-space) даёт только формы canonical-чартов для финал-пака. Per-LOD uv2 строится один раз в конце в original face space, seam-dup в самом конце. +- **Дыра 3 locked-charts:** растворена — финал это fresh-pack всех групп. +- **Дыра 4 accumulated proxy:** растворена — каскад несёт group-labels, membership транзитивна (LOD1 шел → LOD2 группа → корень LOD3). +- **Дыра 5 Poisson per level:** Stage D Poisson'ит LOD[li+1] на каждом шаге цикла. +- **Дыра 6 matched threshold:** Stage D — per-shell matched-fraction порог (старт 0.5, крутим по бенчам). < порог → новая группа. + +## Non-goals + +- НЕ runtime — editor tool +- НЕ skinned mesh (deferred) +- НЕ автоменять lightmapScaleOffset/lightmapIndex пост-бэйка +- НЕ ray-cast fallback в проекции — sentinel честный сигнал Poisson-coverage +- НЕ clamp/scale-to-fit шелов — distortion запрещён +- xatlas = per-shell параметризация + ФИНАЛЬНЫЙ pack всех групп; промежуточного «pack into free space» НЕТ (растворено) diff --git a/Documentation~/REVIEW.md b/Documentation~/REVIEW.md index 8c6c289d..ef6a19f5 100644 --- a/Documentation~/REVIEW.md +++ b/Documentation~/REVIEW.md @@ -16,7 +16,7 @@ When Claude is invoked for PR review (not implementation), follow these rules. ### Package Integrity - [ ] `package.json` version follows semver - [ ] `asmdef` references correct — no circular deps, correct platforms -- [ ] Define symbols (`LIGHTMAP_UV_TOOL_FBX_EXPORTER`) match `versionDefines` +- [ ] Define symbols (`UNITY_MESH_LAB_FBX_EXPORTER`) match `versionDefines` - [ ] No Runtime ↔ Editor dependency leaks ### Backward Compatibility diff --git a/Documentation~/TRANSFER_AUDIT_2026-07-18.md b/Documentation~/TRANSFER_AUDIT_2026-07-18.md new file mode 100644 index 00000000..72f0afc2 --- /dev/null +++ b/Documentation~/TRANSFER_AUDIT_2026-07-18.md @@ -0,0 +1,178 @@ +# Transfer Pipeline Audit — 2026-07-18 + +> Multi-agent audit of BOTH transfer generations. 4 of 9 subsystem +> reviewers completed (classic core, classic repack, SymSplit, +> hierarchical-early) before the run hit the monthly spend limit; the +> adversarial **verification pass never ran**, so every finding below is +> reviewer-proposed, not confirmed — EXCEPT the ones marked ✅ VERIFIED, +> which were spot-checked directly against the code afterwards. +> +> Not audited (finders never ran): orchestration +> (`LightmapTransferTool.cs` full flow), native contract (`XatlasNative.cs` +> call sites), support math (`ArapParameterization`/`CoverageSplitSolver`/ +> BVH numerical stability), docs-drift, and `HierarchicalRepack.cs` +> lines 2300-3750 (Stage E1/E2/**E3 just written**/F). Those remain open. + +## Cross-cutting themes (present in BOTH pipelines) + +These are the systemic classes — the "common problems" — not one-off bugs. + +### T1. Silent zero-UV2 fallbacks reported as success ⚠️ the biggest theme +A failed/aborted/unplaceable shell keeps its allocated `uv2 = (0,0)` +(the atlas origin) and the calling code's only failure signal is +`uv2 == null`. A non-null but all-zero array passes as a valid result, +so the whole shell/LOD bakes into a single texel with no warning. +- `GroupedShellTransfer.cs:931` (+1039/1219/1425/1937) — **✅ VERIFIED**: + `result.uv2` allocated at :897, every cancel checkpoint does + `return result` with the zeroed array; caller checks only `uv2 == null`. + Cancel mid-transfer silently ships a single-texel LOD. +- `GroupedShellTransfer.cs:1361` — unmatched/rejected shells keep + `(0,0)`; `shellsUnmatched` never assigned (benchmark always reports 0). +- `GroupedShellTransfer.cs:4748` — legacy public `Transfer` overload + returns all-zero result with no error. +- `XatlasRepack.cs:1723` — fully-orphaned shells collapse to `(0,0)`; + the orphan branch counts no-op snaps as "fixed". +- `HierarchicalRepack.cs` BuildCascadedUv2 — same shape; the + 2026-07-18 commit at least now emits a Warn + counts `unplacedFaces` + instead of dropping faces, but still parks them at `(0,0)`. + +### T2. The same "deepest / proxy LOD" and "which renderer" computed 4-5 ways +- `HierarchicalRepack.cs` **✅ VERIFIED**: five `deepest`-pickers with + **three different validity criteria** — `:569` uses the prebuilt + `meshes[]` (first *valid* renderer, may be `rs[1]`), `:1649` and + `:1921` test only `rs[0] == null`, `:2034` and `:2136` test + `rs[0] + MeshFilter?.sharedMesh`. On a LODGroup whose `rs[0]` is null + but `rs[1]` valid, `Build()`'s `meshDiag`/seed reference and Stage C/D's + processed LOD disagree — the cascade seeds from a different LOD than the + one that set the reference scale. +- Every stage independently re-picks `renderers[0]` and re-warns on + multi-renderer LODs — multi-mesh-per-LOD is unsupported but the choice + is made inconsistently across stages. +- Classic side has the analogue: source-mesh selection by `meshGroupKey` + in the orchestrator vs. raw shell indices inside the transfer. + +### T3. Absolute epsilons / thresholds that don't scale with mesh size +EXPERIMENTS.md limitation #3 ("пороги не масштабируются") is alive in +many concrete places: +- `GroupedShellTransfer.cs:367` — 3D match gate `max(10% diag, 0.03)` + mixes an absolute world-unit floor with squared-vs-linear distances; + breaks on meshes far from ~1m scale. +- `GroupedShellTransfer.cs:4150` — `CountShellIssues` uses absolute + `1e-10` area epsilon and is blind to fold-overs → folded/collapsed + candidates score 0 issues and are **Accepted**. +- `HierarchicalRepack.cs:776` — canonical-vertex weld uses absolute + `1e-5` floor with no neighbour-cell probe → shell partitions become + position- and scale-dependent (non-deterministic across placements). +- `HierarchicalRepack.cs:1802` — Stage-2 sample UV-bbox cap `0.35` + (absolute) silently drops all samples on legitimately large charts. +- `SymmetrySplitShells.cs:861` — "adaptive" `posFar` degrades to a fixed + `0.125` floor because `DetectFoldCount`/`HasUv0Overlap` are called with + `mesh = null`, inconsistent with the binary stage. + +### T4. Brute-force O(N²)/O(N³) with existing BVHs left unused +- `GroupedShellTransfer.cs:481` — **FindBestSourceShell** re-ranks & sorts + ALL source shells every call, no cache (documented O(N³), still true). +- `GroupedShellTransfer.cs:2059` — Phase-3 face-voting pre-pass is + O(targetFaces × Σ group source faces) with per-pair centroid+cross, + despite `shellBvh3D` already existing. Freezes on Carousel-class groups. +- `SymmetrySplitShells.cs:766` — degenerate/constant-UV0 shell collapses + the spatial hash to one bucket → O(F²) hang + bogus binary split. +- `SymmetrySplitShells.cs:1345` — `SplitWithParams` copies + `mesh.vertices`/`normals` per prescribed param → O(P·S·V). +- `HierarchicalRepack.cs:1195` — Stage-3 projection is O(samples × faces) + brute force with a 3-samples-per-face floor; freezes on 100k-face LODs. + +### T5. Stale counts / struct-copy hazards after post-passes +- `GroupedShellTransfer.cs:3366` — post-transfer 20% AABB safety net + re-projects merged-shell vertices per-vertex, contradicting the + composite path's 2×/30%/50% allowances, with **no re-count** of + `targetShellIssues` (classification at :3487 uses stale counts) and no + composite-contract re-check. Yanks legitimately voted vertices back. +- `HierarchicalRepack.cs` E3 (just written) — `StageEMetrics` is a + struct; mutations after `r.stageEMetrics[li] = m` are lost unless + written back. **Needs review** (hier-late finder never ran). + +## Classic-pipeline-specific + +- **CRITICAL (proposed)** `SymmetrySplitShells.cs:385` — **✅ VERIFIED + present**: the descriptor-distance *fallback* still hard-filters + `p.sourceGroupId != 0 && descriptor.groupId != p.sourceGroupId`. The + fallback exists to be lenient when the exact-signature match fails, but + the groupId gate makes it drop prescribed splits whenever a target LOD's + shell carries a different groupId than the source. Same gate on the + primary path (:365) — so a groupId mismatch across LODs loses the split + on both paths. Severity depends on how often groupId diverges per LOD. +- `GroupedShellTransfer.cs:387` — cross-LOD hints store only a raw + `sourceShellIndex`, validated only against `srcShellCount`. With 2+ mesh + entries per LOD, `accumulatedMatchHints` is cleared per target and + `accumulatedOverlapHints` grows across all targets/LODs → target B gets + target A's hints as indices into the wrong source mesh; hint-matched + shells win dedup priority and evict correct claimants. +- `GroupedShellTransfer.cs:1987` — fragment-merged per-face-voting branch + is gated on `srcShellOverlapMembers[chosenSrc] != null`, but + `MergeFragmentShells` only merges sources with **zero** UV0-bbox overlap + → the gate is always false for fragment-merged shells → the composite + voting is dead code → near-tiling fragments get identical (duplicate) + UV2. (The exact duplicate-lightmap defect the comments claim to prevent.) +- `XatlasRepack.cs:1495` — **✅ VERIFIED store**: `atlasWidth/Height` are + set from `xatlasGetAtlasWidth/Height`; with `internalOversample = 4` + (default per the 2026-05-13 experiment) these are the oversampled dims. + If transfer tolerances scale as `pixels / min(atlasW,atlasH)`, the + margins shrink by the oversample factor. **Confirm** the tolerance + consumer to rate severity. +- `XatlasRepack.cs:782` — heuristic cost budget can refuse to pack + realistic assets at default oversample and report it as "cancelled". +- `LightmapTransferTool.cs:2652` — `ctx.HasRepack = true` even when every + mesh failed to repack; failed entries keep a stale `repackedMesh`. +- `TexelDensityNormalizer.cs:126` — coverage budget makes the density + target absolute; the `[0.1, 10]` scale clamp destroys density uniformity + on tiled or miniature UV0. +- `XatlasRepack.cs:543` — `PostPackDensityCorrection` is default-ON in the + tool despite an "experimental, default false" contract; computes the + shrink pivot over orphan+conflict verts → translates shells into + neighbours. +- `SymmetrySplitShells.cs:641` — `ApplyBinarySplit`/`ApplyNFoldSplit` + rebuild via `mesh.Clear()` and silently destroy blend shapes and + >4-bone skin weights. +- `SymmetrySplitShells.cs:798` — binary split fires on a **single** vote, + no vote-count/ratio gate (asymmetric with the N-fold gate). +- `UvShellExtractor.cs:319` — `BboxOverlapRatio` returns 0 for degenerate + (zero-area/width) shell bboxes → stacked degenerate shells never join + overlap groups. +- `XatlasRepack.cs:1517` — **✅ VERIFIED**: `chartCount = outVertCount` + (mislabels vertex count as chart count). + +## Hierarchical-cascade-specific + +- `HierarchicalRepack.cs:2167` — a null/meshless middle LOD makes + `CascadeGroupShells` `continue` past that transition, **breaking the + cascade chain**: LODs above the gap can't match through it, so their + domains disconnect from the deepest LOD's groups (the exact symptom — + all-zero vs. disconnected-domains — is **unverified**; the chain break + is real by construction). +- `HierarchicalRepack.cs:2157` — Stage D vote has **no normal/orientation + gate** and its distance threshold scales with the whole-mesh diagonal → + thin double-sided geometry (both faces within `overlayDistNorm×diag`) + gets cross-side vote contamination. Front/back of a thin panel merge + into one lighting domain. +- `HierarchicalRepack.cs:2206` — the min-3-samples-per-face floor makes + the Stage D vote **face-count-weighted, not area-weighted**; a shell + that is many tiny faces outvotes a shell that is few large faces. +- `HierarchicalRepack.cs:1626` — a UV0-less mesh produces the Auto proxy + variant but the default `ProxyMode.Clean` never auto-selects it, so + Stage 2/3 silently no-op (empty cascade, no error). +- `HierarchicalRepack.cs:1003` — `MergeAdjacentShells` adjacency misses + shell pairs sharing an edge used by 3+ shells (non-manifold edges). + +## Recommended triage order + +1. **T1 silent-zero-UV2** — turn every `(0,0)` fallback into an explicit + `success=false`/warn so nothing corrupt is ever applied. One shared + result flag across both pipelines. Highest safety-per-effort. +2. **SymmetrySplitShells.cs:385/365 groupId gate** — confirm how often + groupId diverges per-LOD; if ever, the fallback must not hard-filter. +3. **T2 deepest-picker unification** — one `PickDeepest(lods)` helper, + one renderer-selection helper, called everywhere. +4. Run the **missing 4 finders + verification pass** when budget allows — + orchestration error-recovery/leaks, native create/destroy pairing, + PCA/NaN stability, and the freshly-written Stage E3 (never compiled). diff --git a/Documentation~/TRANSFER_BENCHMARK.md b/Documentation~/TRANSFER_BENCHMARK.md index 9cd0153b..41c8c709 100644 --- a/Documentation~/TRANSFER_BENCHMARK.md +++ b/Documentation~/TRANSFER_BENCHMARK.md @@ -71,7 +71,7 @@ strip-parameterization. | `SymmetrySplitShells.LastFallbackCount` / `LastTotalSplitCount` | `Editor/SymmetrySplitShells.cs` | Counters read by the recorder. | | `GroupedShellTransfer.LastTopologyIterations` / `LastTopologyFixed` / `LastTopologyCapHit` | `Editor/GroupedShellTransfer.cs` | Counters for the Laplacian topology pass. | | `UvCanvasView.ValidationFilterMask` | `Editor/Framework/UvCanvasView.cs` | Restricts the validation fill/overlay to selected `TriIssue` bits. | -| `TestSuiteAsset` | `Editor/Settings/TestSuiteAsset.cs` | ScriptableObject registry of benchmark cases (FBX + LOD path + expected ranges). Create via `Assets → Create → Lightmap UV Tool → Test Suite`. | +| `TestSuiteAsset` | `Editor/Settings/TestSuiteAsset.cs` | ScriptableObject registry of benchmark cases (FBX + LOD path + expected ranges). Create via `Assets → Create → Mesh Lab → Sweep Test Suite`. | ## Metrics (one CSV row per mesh × LOD) @@ -91,18 +91,37 @@ Per-row (snapshot of `TransferResult` / `ValidationReport` / static counters): `shellsMerged`, `shellsRejected`, `shellsOverlapFixed` - `dedupConflicts`, `fragmentsMerged`, `consistencyCorrected` - `verticesTransferred`, `verticesTotal` +- `uv2DuplicatePairs`, `compositeBrokenCount`, `severeMismatchCount` + — see *Visual-defect counters* below - `invertedCount`, `stretchedCount`, `zeroAreaCount`, `oobCount`, `cleanCount` - `overlapShellPairs`, `overlapTriangleCount`, `overlapSameSrcPairs` - `texelDensityBadCount`, `texelDensityMedian` - `symSplitFallbackCount`, `symSplitTotalCount` - `topologyIterations`, `topologyFixed`, `topologyCapHit` +### Visual-defect counters + +The original `defectScore = stretched + zeroArea + oob` flagged geometric +defects, but missed the failure modes where the algorithm produces +"technically valid" UV2 that bakes wrong: + +| Metric | What it catches | +| --- | --- | +| `uv2DuplicatePairs` | Pairs of target shells whose quantised UV2 fingerprint hash matches. Non-zero = two distinct 3D instances bake onto the same atlas region (silent lightmap bleeding between symmetric copies). Rejected/Unmatched shells excluded (they legitimately share the empty hash). | +| `shellsOverlapFixed` (== force3D overlap count) | Force3D-fallback shells whose UV2 AABB overlaps a non-fallback shell's UV2 AABB. Already warned via `[GroupedTransfer] UV2 overlap: ... bleeding likely`; surfaced here as a counter. | +| `compositeBrokenCount` | Target shells whose Phase 3 composite UV2 spilled out of the matched source UV2 region (`compArea > 2× srcArea`) and were forced back to single-source fallback. Signals a Phase 2 matching miss. | +| `severeMismatchCount` | Target shells whose chosen source is >10% of mesh diagonal away in 3D. Almost always a wrong-source assignment by Phase 2 — e.g. wedge swapped with a sibling. | + +These four feed into the sweep score (`BenchmarkSweep.Score`) with weights +`-50 / -30 / -10 / -20` respectively, so refactors that drag any of them +upward lose against the previous winner. + JSON output mirrors the CSV but nests `records[]` inside a run envelope. ## Protocol 1. **Prepare a suite.** - `Assets → Create → Lightmap UV Tool → Test Suite`. Add one `TestCase` per + `Assets → Create → Mesh Lab → Sweep Test Suite`. Add one `TestCase` per model; set a short `label` (becomes `runLabel` in CSV), point `fbxAsset` at the FBX, and list your expected ranges in `expectations` (informational; not enforced automatically). @@ -125,17 +144,97 @@ JSON output mirrors the CSV but nests `records[]` inside a run envelope. *Run Full Pipeline* again. Each run produces a separate CSV — diff with a spreadsheet / pandas. -### Parameter sweep (atlasRes × shellPad × borderPad) +### Provenance manifest + auto-archive + +Every sweep run writes a `manifest.json` into `sweep_/` alongside the +`summary.csv` / `winner.json` / `index.html`, and copies the whole +directory into `BenchmarkReports/Archive/.zip` so old runs +stay organised even after many new sweeps. The source directory is left +in place; the zip is non-destructive. + +`manifest.json` records: + +- `package.{name, version, gitSha, gitBranch, gitDirty}` — UPM + PackageInfo + `git rev-parse` (best-effort; blank when the package was + installed via Library/PackageCache without `.git`) +- `unity.{version, platform}` — `Application.unityVersion` / + `Application.platform` +- `host.{user, machine, os, processor}` — `Environment.*` + + `SystemInfo.processorType` +- `sweep.{cellCount, caseCount, sweepLabel, matrix, scoringWeights}` + — a literal mirror of the `SweepMatrix` that drove the run plus a + snapshot of the `BenchmarkSweep.Score` weights at the time + +Use case: when comparing a sweep run today against one from six months +ago, the manifest tells you whether the algorithm constants, package +version, Unity version, or scoring weights changed — so a metric delta +isn't silently caused by something unrelated to the actual change. + +Cross-time tracking: point an external sync target (Google Drive, +Dropbox, OneDrive) at `BenchmarkReports/Archive/` and every sweep auto- +mirrors. The zip filename includes the sweep timestamp so chronological +sort is free. + +### Multi-case sweep (all `TestSuiteAsset.cases[]` in one click) + +For cross-model regression coverage — running the full sweep matrix on +every case in the suite without manually switching FBXes — use **Run +Multi-Case (N × M)**. Located in *Setup → Parameter Sweep*, right of the +single-model **Run Sweep** button. `N` is the number of `cases`, `M` is +the cell count. + +For each case the runner: + +1. Loads the case's `fbxAsset` via `AssetDatabase.LoadAssetAtPath` and + instantiates it into the scene with `PrefabUtility.InstantiatePrefab`, + marking the root `HideFlags.DontSave` (no scene-dirty leakage). +2. Resolves the LODGroup: `lodGroupPath` first (if set), otherwise + `GetComponentInChildren`. Skips the case with a warning if + none found. +3. Calls `ctx.Refresh(lg) + OnRefresh()` to wire it into the tool. +4. Runs `ExecSweep(sweep)` against a per-case subdirectory + `BenchmarkReports/sweep__/` so each model gets its own + `summary.csv` + `winner.json` + `index.html`. +5. Destroys the spawned root in a `finally` block and continues to the + next case. Cancel via the progress strip stops between cases. + +When the loop ends the operator's original `ctx.LodGroup` wiring is +restored. + +Cross-model analysis (pandas): + +```python +import pandas as pd, glob, re +rows = [] +for csv in glob.glob('BenchmarkReports/sweep_*_*/*.csv'): + df = pd.read_csv(csv) + df['model'] = re.search(r'sweep_\d+_\d+_\d+_(.+?)/', csv).group(1) + rows.append(df) +all = pd.concat(rows) +all.groupby(['model','atlasRes','shellPad'])[ + ['uv2DuplicatePairs','severeMismatchCount','shellsOverlapFixed'] +].sum() +``` + +### Parameter sweep (cartesian product of 7 axes) -For automated sweeps across repack parameters, fill `TestSuiteAsset.sweep`: +For automated sweeps, fill `TestSuiteAsset.sweep`. Cells = product of all +array lengths. ``` -atlasResolutions = [256, 512, 2048] -shellPaddingPxVariants = [2, 4, 8, 32] -borderPaddingPxVariants = [0] -resetBetweenRuns = true +atlasResolutions = [256, 512, 2048] # ctx.AtlasResolution +shellPaddingPxVariants = [2, 4, 8, 32] # ctx.ShellPaddingPx +borderPaddingPxVariants = [0] # ctx.BorderPaddingPx +arapIterationsVariants = [0, 50] # 0 = ARAP off; >0 = on with N iters +stretchThresholdVariants = [1.5] # Sander L² gate (only relevant when arap > 0) +internalOversampleVariants = [4] # xatlas internal pack resolution multiplier +symSplitThresholdModeVariants = [LegacyFixed] # LegacyFixed | Adaptive +resetBetweenRuns = true ``` +Per-cell label encodes every axis so the recovery regex can reconstruct +CellConfigs from filenames: `sweep_res{R}_pad{S}_bdr{B}_arap{A}_stretch{T}_os{O}_sym{legacy|adaptive}`. + In *LightmapTransferTool → Setup tab*, assign the asset to the **Sweep suite** field; the neighbouring **Run Sweep (N)** button iterates the cartesian product (N = product of array lengths). Each cell: @@ -193,7 +292,7 @@ When a run is noisy (e.g. Adaptive threshold messages spam the console), open *Pipeline Settings → Log filters* and uncheck the offending `UvtLog.Category`. Verbosity (`Level`) still controls global threshold; the mask is an additional silencer persisted per user in EditorPrefs -(`LightmapUvTool_LogCategoryMask`). +(`UnityMeshLab_LogCategoryMask`). | Category | Typical messages | | --- | --- | @@ -232,6 +331,15 @@ list. - **Topology cap hit:** false. If true, either increase `kMaxTopologyIterations` or accept residual displacement. - **Coverage** (`verticesTransferred / verticesTotal`): >= 0.99. +- **uv2DuplicatePairs:** 0. Non-zero = silent lightmap bleeding; STOP. +- **shellsOverlapFixed (force3D overlap):** 0 on source LOD. Up to 1 + tolerable on target LODs (only on heavily-decimated geometry). +- **compositeBrokenCount:** 0 on source LOD. Up to ~5% of target shell + count tolerable; higher = Phase 2 matching is misassigning to source + shells that don't cover the target UV0 region. +- **severeMismatchCount:** 0 on source LOD. Non-zero on target LODs is a + strong signal that a wedge / sibling got swapped — investigate the + specific shells (`shellMatchDistSqr` column). ## Known models diff --git a/Documentation~/TRANSFER_TEST_PLAN.md b/Documentation~/TRANSFER_TEST_PLAN.md new file mode 100644 index 00000000..34289a6b --- /dev/null +++ b/Documentation~/TRANSFER_TEST_PLAN.md @@ -0,0 +1,150 @@ +# UV Transfer — Debug & Test Plan + +> Цель: систематически локализовать, **где** и **на каком классе ассетов** transfer +> портит lightmap UV2 — по объективным метрикам, а не на глаз. Работает для обоих +> поколений: классический (`GroupedShellTransfer` + `XatlasRepack`) и каскад +> (`HierarchicalRepack`, стадии A→F). +> +> Компиляцию/бенч гоняет пользователь в Unity; артефакты приходят под +> `bench_*/{case}/hier/`. Принцип лестницы: **простое → сложное**, каждый шаг ловит +> один класс дефекта. Не переходить на следующую ступень, пока текущая не зелёная. + +## 0. Инварианты перед любым тестом + +- **Детерминизм.** Один и тот же вход два прогона → **идентичный** UV2 (hash + `finalUv2`/`proxyUv2`). Если нет — сначала чинить недетерминизм (seed LCG, + порядок renderer'ов, union-find), отладка иначе бессмысленна. +- **Один рычаг за раз.** Менять один параметр `Options`/один фикс на прогон + (правило EXPERIMENTS.md). Ломает — реверт, не компенсировать. +- **A/B baseline.** Каждый прогон сравнивать с зафиксированным baseline (тот же + ассет, дефолтные `Options`). Метрика без baseline — шум. + +## 1. Лестница фикстур (по возрастанию сложности) + +| # | Фикстура | Что изолирует | Зелёный критерий | +|---|---|---|---| +| **F0** | Unit cube (6 квадов, 1 shell, без симметрии) | тождество: тривиальный transfer не должен ничего портить | overlap=0, inverted=0, tpuSpread≈1.0, unplaced=0 | +| **F1** | Симметричный бокс / mirrored prop | SymSplit left/right, mirror-handling | нет duplicate-UV2, симметричные shell'ы в РАЗНЫХ областях атласа | +| **F2** | Одиночный цилиндр / дуга (кривой shell) | фолдинг, вырождение планар-проекции, кривой canonical | inverted=0, overlapPct<1%, чарт — не «бабочка» | +| **F3** | Ряд одинаковых инстансов (гвозди/доски, общий UV0) | fragment-merge, дубль-UV2 (дефект T1 аудита) | uv2DuplicatePairs=0, каждый инстанс — свой слот | +| **F4** | 2-LOD простой (куб LOD0/LOD1) | кросс-LOD согласованность (базовая) | xLodContainedPct>95%, домен в одной области на обоих LOD | +| **F5** | Тонкая двусторонняя панель (2 LOD) | Stage D без normal-gate сливает перед/зад (аудит) | перед/зад — РАЗНЫЕ домены, overlap=0 | +| **F6** | Full suite: Gazebo, Carousel, Playground, WoodenBox | реальная сложность (арки, обод 92-shell, трубы, панели+трим) | пороги §4 по каждому кейсу | + +F0–F5 — синтетические, делаются за минуты в Unity, ловят 80% регрессий до дорогого F6. + +## 2. Пороговые гейты (объективные, из `stage_e_metrics.csv`) + +Каскад пишет per-LOD строку. Гейты pass/fail: + +| Метрика | Плоская геометрия (WoodenBox/Gazebo) | Кривая/плотная (Carousel/Playground) | +|---|---|---| +| `unplacedFaces` | **0** (>0 = дыра placement'а, Stage E1) | **0** | +| `invertedFaces` | **0** | ≤ 0.5% faces | +| `degenUvFaces` | ≤ 0.1% | ≤ 1% | +| `oobVerts` | **0** | **0** | +| `overlapPctOfCovered` | **< 0.5%** | < 3% | +| `overlapShellPairs` | 0 | небольшое, отслеживать тренд | +| `tpuSpread` (p99/p1) | **< 1.3×** | < 2.0× | +| `xLodContainedPct` | **> 90%** | > 85% | +| `misalignedGroups` | **0** | ≤ 2 | +| `utilizationPct` | > 45% (packEff=0.5) | > 45% | + +**Красный флаг > всего:** `xLodContainedPct` низкий = ассет нарушает допущение +«LOD'ы делят UV0-layout» → один бейк НЕ валиден через LOD → каскад тут неприменим, +это не баг кода, а геометрия. Проверять первым. + +Классический путь — те же по духу, через `TransferResult`: +`uv2DuplicatePairs` (=0), `compositeBrokenCount` (=0), `severeMismatchCount` (=0), +`shellsUnmatched` (bounded), overlap-пары, inverted. + +## 3. Постадийная изоляция каскада (A→F) + +Не только end-to-end — проверять артефакт каждой стадии. Порядок диагностики при +красной метрике: идти по стадиям сверху вниз, первая аномалия = корень. + +| Стадия | Артефакт | Что проверять | +|---|---|---| +| 1 proxy UV2 | `proxy_uv2_{clean,raw,auto}.png` | UV в [0,1], без грубых наложений; clean — чистая раскладка | +| B classical | `lodN_classical_uv2.png` | каждый LOD разворачивается без мусора | +| 2 samples | `proxy_samples.png` | плотность равномерна, тонкие shell'ы не голодают | +| 3 projection | `lodN_proxy_hits.png` | `missed`≈0, покрытие сплошное (нет чёрных дыр) | +| C shells | `lodN_shells.png` | стабильное число shell'ов, нет дегенератов | +| D cascade | `lodN_groups.png` | один домен = один цвет через LOD; `missed=0`; `tinyOrphan` ограничен | +| E1 pack | `domains_atlas.png` | чарты = реальные UV0-острова (не синусоиды); `packedGroups==groups` | +| E2 cascade | `lodN_final_uv2.png` | домен в ОДНОЙ области на всех LOD; `unplacedFaces=0` | +| E3 metrics | `lodN_overlap.png` + CSV | красных текселей нет на плоском; §2 гейты | +| F meshes | (Apply через меню) | `finalMeshes[li]` face-count == source; submesh'и/материалы целы | + +Меню для ручного end-to-end: **`Mesh Lab/Hier/Apply UV2 to Selected LODGroup`** +(диалог теперь показывает overlap px / unplaced / misaligned — хедлайн E3). + +## 4. Регрессионные тесты из аудита (`TRANSFER_AUDIT_2026-07-18.md`) + +Каждую находку аудита — в воспроизводимый тест: + +- **T1 silent zero-UV2.** Ни один shell не должен получить весь UV2=(0,0), кроме + явного fallback. Тест: прогнать F0–F3, grep лог на `unplacedFaces>0` и Warn + «no valid domain placement». Отдельно: **отмена посреди transfer** не должна + оставлять сохранённый занулённый результат (класс.: `GroupedShellTransfer:931`). +- **T2 divergent deepest-picker.** LODGroup, где `renderers[0]==null`, но + `renderers[1]` валиден. Каскад должен выбрать один LOD согласованно на всех + стадиях (не рассинхрон meshDiag vs Stage C). +- **T3 масштаб-инвариантность.** Один ассет × 0.01 и × 100 (globalScale) → + **идентичные** метрики. Расхождение = абсолютные пороги (`1e-5` weld, `0.35` + UV-bbox, `max(10%diag,0.03)`) ломаются на нестандартном масштабе. +- **T4 brute-force perf.** Ассет 100k faces + группа 50+ shell'ов (Carousel-класс). + Замерить wall-time Stage 3 проекции и `FindBestSourceShell`. Флаг: >30 с на LOD. +- **T5 fold-over blind (класс.).** Shell, сложенный сам на себя (кривой). Проверить, + что `CountShellIssues` его НЕ принимает за 0 issues (абс. `1e-10` epsilon). +- **F5 double-sided (каскад).** Тонкая панель: Stage D без normal-gate сольёт + перед/зад в один домен → проверить `xLodContainedPct` и что перед/зад — разные + группы в `lodN_groups.png`. + +## 5. Свип порогов (когда домены грязные) + +`stage_d_sweep.csv` теперь несёт E3-скаляр per-cell (`e3OverlapTexels`, +`e3XLodMinPct`, `e3MisalignedGroups`, `e3UnplacedFaces`). Процедура: +1. Прогнать sweep `cascadeMatchFrac × cascadeMinHits` (дефолт `{0.35,0.5,0.65}×{2,4,8}`). +2. Выбрать ячейку с **min `e3OverlapTexels`** при `e3XLodMinPct` близком к 100 и + `e3UnplacedFaces=0`. Это объективный winner (раньше свип был только визуальный). +3. Зафиксировать как новый `Options.Default`, пере-прогнать F6, сверить с baseline. + +## 6. Дерево триажа (пришёл плохой бенч — что смотреть) + +``` +overlapPct высокий? +├─ на ПЛОСКОМ ассете (WoodenBox) → mirror-reuse UV0 между shell'ами; +│ смотреть lodN_overlap.png (красное), +│ overlapShellPairs → какие shell'ы +├─ только на КРИВОМ (Carousel обод) → фолдинг canonical'а (Stage E1); +│ domains_atlas.png = «бабочки» +xLodContainedPct низкий? → ассет не делит UV0-layout LOD'ов +│ (не баг — геометрия) ИЛИ Stage D +│ разнёс домен; смотреть lodN_groups.png +unplacedFaces > 0? → Stage E1 не дал placement группе; +│ degenerate-axis LSQ fallback лог +tpuSpread высокий? → тексель-density неравномерна; +│ проверить texelsPerUnit / S-нормировку +inverted > 0? → перевёрнутая намотка при проекции +``` + +## 7. Контракт артефактов (что присылать мне) + +7z с `bench_/{case}/hier/`, обязательно: +- `stage_e_metrics.csv` (главный объективный сигнал), +- `stage_d_sweep.csv` (если гонялся свип), +- `domains_atlas.png`, `lod*_final_uv2.png`, `lod*_overlap.png`, `lod*_groups.png`, +- консольный лог с строками `[HierRepack] Stage E3:` (per-LOD цифры). + +По CSV+PNG я локализую стадию-корень без Unity. Сырые метрики > словесное описание. + +## 8. Definition of Done для «transfer работает надёжно» + +- F0–F5 зелёные по §2 на дефолтных `Options`, детерминированно (2 прогона = hash). +- F6 (4 кейса): `unplacedFaces=0`, `inverted=0`, `overlapPct` в пороге §2, + `xLodContainedPct>85%` везде. +- T3 масштаб-инвариантность выполнена (× 0.01 / × 100 идентичны). +- Ни одной silent-zero-UV2 (T1) в логах. +- Apply на реальном LODGroup → бейк лайтмапа валиден при переключении LOD (глазами + один раз, дальше — по `xLodContainedPct`). diff --git a/Editor/BenchmarkRecorder.cs b/Editor/BenchmarkRecorder.cs index cca64afa..821c7b93 100644 --- a/Editor/BenchmarkRecorder.cs +++ b/Editor/BenchmarkRecorder.cs @@ -28,6 +28,14 @@ public sealed class BenchmarkRecorder : IDisposable /// public static string LastWrittenCsvPath { get; private set; } + /// When non-null, every BenchmarkRecorder session's + /// writes its CSV/JSON/PNG output into + /// this directory instead of the default BenchmarkReports/ + /// top level. ExecBenchmark sets this to the per-case directory so + /// every cell's artefacts land alongside hier_repack.csv etc. + /// Outside-of-bench callers leave it null. + public static string OutputDirectoryOverride; + // Sentinel for nested calls — caller treats it as a scope that does nothing on Dispose. sealed class NoOpScope : IDisposable { public static readonly NoOpScope Instance = new NoOpScope(); public void Dispose() { } } @@ -65,6 +73,7 @@ public sealed class BenchmarkRecorder : IDisposable readonly bool arapEnabled; readonly int arapIterations; readonly float stretchThreshold; + readonly int internalOversample; // TODO: capture actualAtlasWidth/actualAtlasHeight from RepackResult. // Currently RepackResult is consumed inside ExecRepackCore and not // surfaced on MeshEntry. Threading it through would require a new @@ -85,9 +94,10 @@ public sealed class BenchmarkRecorder : IDisposable shellPad = ctx?.ShellPaddingPx ?? 0; borderPad = ctx?.BorderPaddingPx ?? 0; sourceLodIndex = ctx?.SourceLodIndex ?? 0; - arapEnabled = ctx?.ReparameterizeStretchedShells ?? false; - arapIterations = ctx?.ArapIterations ?? 0; - stretchThreshold = ctx?.StretchThreshold ?? 0f; + arapEnabled = ctx?.ReparameterizeStretchedShells ?? false; + arapIterations = ctx?.ArapIterations ?? 0; + stretchThreshold = ctx?.StretchThreshold ?? 0f; + internalOversample = ctx?.InternalOversample ?? 1; modeTag = $"{symSplitMode}{(repackPerMesh ? "-perMesh" : "")}{(splitTargets ? "-splitTgt" : "")}"; startedAtUtc = DateTime.UtcNow; @@ -208,6 +218,9 @@ public void RecordMesh(MeshEntry entry) consistencyCorrected = tr?.consistencyCorrected ?? 0, verticesTransferred = tr?.verticesTransferred ?? 0, verticesTotal = tr?.verticesTotal ?? 0, + uv2DuplicatePairs = tr?.uv2DuplicatePairs ?? 0, + compositeBrokenCount = tr?.compositeBrokenCount ?? 0, + severeMismatchCount = tr?.severeMismatchCount ?? 0, invertedCount = v?.invertedCount ?? 0, stretchedCount = v?.stretchedCount ?? 0, @@ -275,8 +288,27 @@ void WriteArtefacts() // Bare repack/transfer runs without RecordMesh calls aren't worth a file. if (records.Count == 0) return; - string projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; - string dir = Path.Combine(projectRoot, "BenchmarkReports"); + string dir; + if (!string.IsNullOrEmpty(OutputDirectoryOverride)) + { + dir = OutputDirectoryOverride; + } + else + { + // Default path: ALWAYS in a dated subfolder, never the + // BenchmarkReports/ root. Standalone tool actions (Run Full + // Pipeline, Run Repack only, Run Transfer only) don't set the + // override; without this their CSV/JSON/PNG output would pile + // up at the top level, mixed across sessions, and hide any + // bench_/ folder that the unified benchmark produces in + // the same directory. One subfolder per BenchmarkRecorder + // session, named by the session start time, keeps the top + // level clean. + string projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + string sessionStamp = startedAtUtc.ToString("yyyy-MM-dd_HH-mm-ss", + System.Globalization.CultureInfo.InvariantCulture); + dir = Path.Combine(projectRoot, "BenchmarkReports", $"run_{sessionStamp}"); + } Directory.CreateDirectory(dir); // Millisecond-precision timestamp — second-level collided when an @@ -295,8 +327,15 @@ void WriteArtefacts() LastWrittenCsvPath = csvPath; // Per-mesh UV2 snapshots, one PNG per recorded mesh. + // Subfolder is a SHORT fixed name (was fileBase + "_png" but + // that drove the full path past Windows MAX_PATH = 260 on + // assets with long lodGroup / modeTag combos, e.g. + // 02_Wooden_Box_Long/legacy/{long}_png/{file}.png = + // ≈290 chars and OS rejects). Parent dir already + // discriminates the cell uniquely. int pngCount = 0; - string pngDir = Path.Combine(dir, fileBase + "_png"); + string pngDir = Path.Combine(dir, "uv2_png"); + Directory.CreateDirectory(pngDir); foreach (var r in records) { if (r.uv2Snapshot == null || r.trianglesSnapshot == null) continue; @@ -315,12 +354,13 @@ string BuildCsv() var sb = new StringBuilder(); sb.AppendLine("timestamp,runLabel,lodGroup,symSplitMode,repackPerMesh,splitTargets," + "atlasRes,shellPad,borderPad," + - "arapEnabled,arapIterations,stretchThreshold," + + "arapEnabled,arapIterations,stretchThreshold,internalOversample," + "sourceLod," + "rendererName,meshGroupKey,lodIndex,isSourceLod," + "shellsMatched,shellsUnmatched,shellsTransform,shellsInterpolation,shellsMerged," + "shellsRejected,shellsOverlapFixed,dedupConflicts,fragmentsMerged,consistencyCorrected," + "verticesTransferred,verticesTotal," + + "uv2DuplicatePairs,compositeBrokenCount,severeMismatchCount," + "invertedCount,stretchedCount,zeroAreaCount,oobCount,cleanCount," + "overlapShellPairs,overlapTriangleCount,overlapSameSrcPairs," + "texelDensityBadCount,texelDensityMedian," + @@ -348,6 +388,7 @@ string BuildCsv() sb.Append(arapEnabled ? '1' : '0').Append(','); sb.Append(arapIterations.ToString(inv)).Append(','); sb.Append(stretchThreshold.ToString("R", inv)).Append(','); + sb.Append(internalOversample.ToString(inv)).Append(','); sb.Append(sourceLodIndex.ToString(inv)).Append(','); sb.Append(Csv(r.rendererName)).Append(','); sb.Append(Csv(r.meshGroupKey)).Append(','); @@ -365,6 +406,9 @@ string BuildCsv() sb.Append(r.consistencyCorrected.ToString(inv)).Append(','); sb.Append(r.verticesTransferred.ToString(inv)).Append(','); sb.Append(r.verticesTotal.ToString(inv)).Append(','); + sb.Append(r.uv2DuplicatePairs.ToString(inv)).Append(','); + sb.Append(r.compositeBrokenCount.ToString(inv)).Append(','); + sb.Append(r.severeMismatchCount.ToString(inv)).Append(','); sb.Append(r.invertedCount.ToString(inv)).Append(','); sb.Append(r.stretchedCount.ToString(inv)).Append(','); sb.Append(r.zeroAreaCount.ToString(inv)).Append(','); @@ -408,9 +452,10 @@ string BuildJson() AppendJsonKv(sb, "atlasResolution", atlasResolution); sb.Append(",\n"); AppendJsonKv(sb, "shellPad", shellPad); sb.Append(",\n"); AppendJsonKv(sb, "borderPad", borderPad); sb.Append(",\n"); - AppendJsonKv(sb, "arapEnabled", arapEnabled); sb.Append(",\n"); - AppendJsonKv(sb, "arapIterations", arapIterations); sb.Append(",\n"); - AppendJsonKv(sb, "stretchThreshold", stretchThreshold); sb.Append(",\n"); + AppendJsonKv(sb, "arapEnabled", arapEnabled); sb.Append(",\n"); + AppendJsonKv(sb, "arapIterations", arapIterations); sb.Append(",\n"); + AppendJsonKv(sb, "stretchThreshold", stretchThreshold); sb.Append(",\n"); + AppendJsonKv(sb, "internalOversample", internalOversample); sb.Append(",\n"); AppendJsonKv(sb, "sourceLodIndex", sourceLodIndex); sb.Append(",\n"); AppendJsonKv(sb, "pipelineMs", pipelineMs); sb.Append(",\n"); AppendJsonKv(sb, "repackMs", repackMs); sb.Append(",\n"); @@ -439,6 +484,9 @@ string BuildJson() AppendJsonKv(sb, "consistencyCorrected", r.consistencyCorrected); sb.Append(", "); AppendJsonKv(sb, "verticesTransferred", r.verticesTransferred); sb.Append(", "); AppendJsonKv(sb, "verticesTotal", r.verticesTotal); sb.Append(", "); + AppendJsonKv(sb, "uv2DuplicatePairs", r.uv2DuplicatePairs); sb.Append(", "); + AppendJsonKv(sb, "compositeBrokenCount", r.compositeBrokenCount); sb.Append(", "); + AppendJsonKv(sb, "severeMismatchCount", r.severeMismatchCount); sb.Append(", "); AppendJsonKv(sb, "invertedCount", r.invertedCount); sb.Append(", "); AppendJsonKv(sb, "stretchedCount", r.stretchedCount); sb.Append(", "); AppendJsonKv(sb, "zeroAreaCount", r.zeroAreaCount); sb.Append(", "); @@ -524,6 +572,10 @@ public class RunRecord public int shellsRejected, shellsOverlapFixed, dedupConflicts, fragmentsMerged, consistencyCorrected; public int verticesTransferred, verticesTotal; + // Visual-defect counters surfaced for sweep scoring. See + // GroupedShellTransfer.TransferResult for field semantics. + public int uv2DuplicatePairs, compositeBrokenCount, severeMismatchCount; + public int invertedCount, stretchedCount, zeroAreaCount, oobCount, cleanCount; public int overlapShellPairs, overlapTriangleCount, overlapSameSrcPairs; public int texelDensityBadCount; diff --git a/Editor/BenchmarkSweep.cs b/Editor/BenchmarkSweep.cs index 7ea6abc8..80c18c9f 100644 --- a/Editor/BenchmarkSweep.cs +++ b/Editor/BenchmarkSweep.cs @@ -9,8 +9,15 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Text; using UnityEngine; +using UnityEditor; +// Disambiguate types both namespaces expose so the file compiles cleanly: +// UnityEditor.PackageInfo (legacy Asset Store metadata) vs UnityEditor.PackageManager.PackageInfo (UPM) +// UnityEngine.CompressionLevel (texture compression) vs System.IO.Compression.CompressionLevel (zip) +using PackageInfo = UnityEditor.PackageManager.PackageInfo; +using CompressionLevel = System.IO.Compression.CompressionLevel; namespace SashaRX.UnityMeshLab { @@ -28,6 +35,16 @@ internal static class BenchmarkSweep const float kPenaltyOverlap = -10f; const float kPenaltyMs = -0.001f; const float kPenaltyResolution = -10f; + // Visual-defect weights. duplicate UV2 pairs are the silent killer — + // two distinct instances baking onto the same atlas region. Weighted + // as heavy as a sliver because the user-visible effect is comparable. + // force3D overlaps are explicit bleeding (already warned via + // shellsOverlapFixed). composite-broken and severe-mismatch are + // matching-quality signals — lighter penalty. + const float kPenaltyDupUv2 = -50f; + const float kPenaltyForce3DOL = -30f; + const float kPenaltyCompBroken = -10f; + const float kPenaltySevere = -20f; /// /// Snapshot of the ctx fields that distinguish one sweep cell from @@ -47,6 +64,14 @@ internal struct CellConfig public bool arapEnabled; public int arapIterations; public float stretchThreshold; + // xatlas internal-oversample. internalRes = resolution × oversample. + // 1 = native resolution, 4 = current default. See EXPERIMENTS.md + // a218a2b — affects Stage B density amplification. + public int internalOversample; + // SymSplit threshold mode (LegacyFixed vs Adaptive). Serialised + // separately from arapEnabled because it gates a different + // pre-pack stage. + public SymmetrySplitShells.ThresholdMode symSplitMode; } internal struct RunSummary @@ -60,6 +85,11 @@ internal struct RunSummary public long totalMs; // sum(pipeline+repack+transfer+validate) public float score; public bool hadFailure; + // Visual-defect counters, summed across target LODs. + public int uv2DuplicatePairs; + public int force3DOverlapCount; // sum of shellsOverlapFixed + public int compositeBrokenCount; + public int severeMismatchCount; } /// @@ -105,10 +135,15 @@ internal static void WriteAggregateReport(List csvPaths, List_png/". With the new layout the thumbnails + // are children of sweepDir, so BuildThumbsCell looks inside it + // and the relative link is just "_png/". + string reportsDir = sweepDir; var summaries = new List(n); for (int i = 0; i < n; i++) @@ -193,7 +228,8 @@ internal static void WriteAggregateReport(List csvPaths, List @@ -202,8 +238,12 @@ internal static void WriteAggregateReport(List csvPaths, List /// Failed runs (hadFailure=true) get -∞ so they never win the sweep. /// @@ -216,6 +256,10 @@ internal static float Score(RunSummary r) return kWeightUtilization * r.meanAtlasUtilization + kPenaltySliver * r.totalSlivers + kPenaltyOverlap * r.overlapShellPairs + + kPenaltyDupUv2 * r.uv2DuplicatePairs + + kPenaltyForce3DOL * r.force3DOverlapCount + + kPenaltyCompBroken * r.compositeBrokenCount + + kPenaltySevere * r.severeMismatchCount + kPenaltyMs * r.totalMs + resPenalty; } @@ -263,8 +307,13 @@ int idx(string col) int iValidate = idx("validateMs"); int iShellsMatch = idx("shellsMatched"); int iVertsXfer = idx("verticesTransferred"); + int iDupUv2 = idx("uv2DuplicatePairs"); + int iForce3DOL = idx("shellsOverlapFixed"); + int iCompBroken = idx("compositeBrokenCount"); + int iSevere = idx("severeMismatchCount"); int slivers = 0, overlap = 0; + int dupUv2 = 0, force3DOL = 0, compBroken = 0, severe = 0; int utilCount = 0; float utilSum = 0f; long totalMs = 0; @@ -291,6 +340,10 @@ int idx(string col) slivers += SafeInt(c, iInverted) + SafeInt(c, iStretched) + SafeInt(c, iZero) + SafeInt(c, iOob); overlap += SafeInt(c, iOverlap); + dupUv2 += SafeInt(c, iDupUv2); + force3DOL += SafeInt(c, iForce3DOL); + compBroken += SafeInt(c, iCompBroken); + severe += SafeInt(c, iSevere); // A target-LOD row with zero shells matched AND zero // vertices transferred means the transfer never ran (or @@ -340,6 +393,10 @@ int idx(string col) summary.totalSlivers = slivers; summary.overlapShellPairs = overlap; + summary.uv2DuplicatePairs = dupUv2; + summary.force3DOverlapCount = force3DOL; + summary.compositeBrokenCount = compBroken; + summary.severeMismatchCount = severe; summary.meanAtlasUtilization = utilCount > 0 ? utilSum / utilCount : 0f; summary.totalMs = totalMs; // No target-LOD rows means transfer never produced anything — @@ -399,7 +456,10 @@ internal static void WriteSummaryCsv(string path, List runs, string var inv = CultureInfo.InvariantCulture; var sb = new StringBuilder(); sb.AppendLine("atlasRes,shellPad,borderPad,arapEnabled,arapIterations,stretchThreshold," + - "totalSlivers,overlapShellPairs,meanAtlasUtilization,totalMs,score,csvPath"); + "internalOversample,symSplitMode," + + "totalSlivers,overlapShellPairs," + + "uv2DuplicatePairs,force3DOverlapCount,compositeBrokenCount,severeMismatchCount," + + "meanAtlasUtilization,totalMs,score,csvPath"); foreach (var r in runs) { // Make csvPath relative to BenchmarkReports/ when possible — @@ -418,8 +478,14 @@ internal static void WriteSummaryCsv(string path, List runs, string sb.Append(r.config.arapEnabled ? '1' : '0').Append(','); sb.Append(r.config.arapIterations.ToString(inv)).Append(','); sb.Append(r.config.stretchThreshold.ToString("R", inv)).Append(','); + sb.Append(r.config.internalOversample.ToString(inv)).Append(','); + sb.Append(r.config.symSplitMode.ToString()).Append(','); sb.Append(r.totalSlivers.ToString(inv)).Append(','); sb.Append(r.overlapShellPairs.ToString(inv)).Append(','); + sb.Append(r.uv2DuplicatePairs.ToString(inv)).Append(','); + sb.Append(r.force3DOverlapCount.ToString(inv)).Append(','); + sb.Append(r.compositeBrokenCount.ToString(inv)).Append(','); + sb.Append(r.severeMismatchCount.ToString(inv)).Append(','); sb.Append(r.meanAtlasUtilization.ToString("R", inv)).Append(','); sb.Append(r.totalMs.ToString(inv)).Append(','); sb.Append(r.score.ToString("R", inv)).Append(','); @@ -444,9 +510,15 @@ internal static void WriteWinnerJson(string path, List runs, int bes sb.Append(" \"arapEnabled\": ").Append(w.config.arapEnabled ? "true" : "false").Append(",\n"); sb.Append(" \"arapIterations\": ").Append(w.config.arapIterations.ToString(inv)).Append(",\n"); sb.Append(" \"stretchThreshold\": ").Append(w.config.stretchThreshold.ToString("R", inv)).Append(",\n"); + sb.Append(" \"internalOversample\": ").Append(w.config.internalOversample.ToString(inv)).Append(",\n"); + sb.Append(" \"symSplitMode\": ").Append(JsonString(w.config.symSplitMode.ToString())).Append(",\n"); sb.Append(" \"score\": ").Append(JsonFloat(w.score, inv)).Append(",\n"); sb.Append(" \"totalSlivers\": ").Append(w.totalSlivers.ToString(inv)).Append(",\n"); sb.Append(" \"overlapShellPairs\": ").Append(w.overlapShellPairs.ToString(inv)).Append(",\n"); + sb.Append(" \"uv2DuplicatePairs\": ").Append(w.uv2DuplicatePairs.ToString(inv)).Append(",\n"); + sb.Append(" \"force3DOverlapCount\": ").Append(w.force3DOverlapCount.ToString(inv)).Append(",\n"); + sb.Append(" \"compositeBrokenCount\": ").Append(w.compositeBrokenCount.ToString(inv)).Append(",\n"); + sb.Append(" \"severeMismatchCount\": ").Append(w.severeMismatchCount.ToString(inv)).Append(",\n"); sb.Append(" \"meanAtlasUtilization\": ").Append(JsonFloat(w.meanAtlasUtilization, inv)).Append(",\n"); sb.Append(" \"totalMs\": ").Append(w.totalMs.ToString(inv)).Append(",\n"); sb.Append(" \"csvPath\": ").Append(JsonString(w.csvPath ?? "")).Append("\n"); @@ -458,6 +530,10 @@ internal static void WriteWinnerJson(string path, List runs, int bes sb.Append(" \"atlasUtilizationWeight\": ").Append(kWeightUtilization.ToString("R", inv)).Append(",\n"); sb.Append(" \"sliverPenalty\": ").Append(kPenaltySliver.ToString("R", inv)).Append(",\n"); sb.Append(" \"overlapPenalty\": ").Append(kPenaltyOverlap.ToString("R", inv)).Append(",\n"); + sb.Append(" \"dupUv2Penalty\": ").Append(kPenaltyDupUv2.ToString("R", inv)).Append(",\n"); + sb.Append(" \"force3DOverlapPenalty\": ").Append(kPenaltyForce3DOL.ToString("R", inv)).Append(",\n"); + sb.Append(" \"compositeBrokenPenalty\": ").Append(kPenaltyCompBroken.ToString("R", inv)).Append(",\n"); + sb.Append(" \"severeMismatchPenalty\": ").Append(kPenaltySevere.ToString("R", inv)).Append(",\n"); sb.Append(" \"msPenalty\": ").Append(kPenaltyMs.ToString("R", inv)).Append(",\n"); sb.Append(" \"resolutionPenalty\": ").Append(kPenaltyResolution.ToString("R", inv)).Append("\n"); sb.Append(" },\n"); @@ -473,8 +549,14 @@ internal static void WriteWinnerJson(string path, List runs, int bes sb.Append("\"arapEnabled\": ").Append(r.config.arapEnabled ? "true" : "false").Append(", "); sb.Append("\"arapIterations\": ").Append(r.config.arapIterations.ToString(inv)).Append(", "); sb.Append("\"stretchThreshold\": ").Append(r.config.stretchThreshold.ToString("R", inv)).Append(", "); + sb.Append("\"internalOversample\": ").Append(r.config.internalOversample.ToString(inv)).Append(", "); + sb.Append("\"symSplitMode\": ").Append(JsonString(r.config.symSplitMode.ToString())).Append(", "); sb.Append("\"totalSlivers\": ").Append(r.totalSlivers.ToString(inv)).Append(", "); sb.Append("\"overlapShellPairs\": ").Append(r.overlapShellPairs.ToString(inv)).Append(", "); + sb.Append("\"uv2DuplicatePairs\": ").Append(r.uv2DuplicatePairs.ToString(inv)).Append(", "); + sb.Append("\"force3DOverlapCount\": ").Append(r.force3DOverlapCount.ToString(inv)).Append(", "); + sb.Append("\"compositeBrokenCount\": ").Append(r.compositeBrokenCount.ToString(inv)).Append(", "); + sb.Append("\"severeMismatchCount\": ").Append(r.severeMismatchCount.ToString(inv)).Append(", "); sb.Append("\"meanAtlasUtilization\": ").Append(JsonFloat(r.meanAtlasUtilization, inv)).Append(", "); sb.Append("\"totalMs\": ").Append(r.totalMs.ToString(inv)).Append(", "); sb.Append("\"score\": ").Append(JsonFloat(r.score, inv)).Append(", "); @@ -512,7 +594,8 @@ internal static void WriteGalleryHtml(string path, List runs, int be var w = runs[bestIdx]; winnerLabel = $"res={w.config.atlasRes}, pad={w.config.shellPad}, bdr={w.config.borderPad}, " + $"arap={(w.config.arapEnabled ? w.config.arapIterations : 0)}, " + - $"stretchThr={w.config.stretchThreshold.ToString("F2", inv)}"; + $"stretchThr={w.config.stretchThreshold.ToString("F2", inv)}, " + + $"os={w.config.internalOversample}, sym={w.config.symSplitMode}"; winnerScore = w.score.ToString("F2", inv); } @@ -557,11 +640,17 @@ internal static void WriteGalleryHtml(string path, List runs, int be sb.Append(" arapEnabled\n"); sb.Append(" arapIters\n"); sb.Append(" stretchThr\n"); - sb.Append(" slivers\n"); - sb.Append(" overlap\n"); - sb.Append(" atlas%\n"); - sb.Append(" ms\n"); - sb.Append(" score\n"); + sb.Append(" os\n"); + sb.Append(" symMode\n"); + sb.Append(" slivers\n"); + sb.Append(" overlap\n"); + sb.Append(" dupUV2\n"); + sb.Append(" f3DOL\n"); + sb.Append(" compBr\n"); + sb.Append(" 10% mesh diagonal\">severe\n"); + sb.Append(" atlas%\n"); + sb.Append(" ms\n"); + sb.Append(" score\n"); sb.Append(" UV2 thumbs\n"); sb.Append(" \n"); sb.Append(" \n"); @@ -579,12 +668,20 @@ internal static void WriteGalleryHtml(string path, List runs, int be sb.Append(" ").Append(r.config.arapEnabled ? "1" : "0").Append("\n"); sb.Append(" ").Append(r.config.arapIterations.ToString(inv)).Append("\n"); sb.Append(" ").Append(r.config.stretchThreshold.ToString("F2", inv)).Append("\n"); + sb.Append(" ").Append(r.config.internalOversample.ToString(inv)).Append("\n"); + sb.Append(" ").Append(HtmlEscape(r.config.symSplitMode.ToString())).Append("\n"); sb.Append(" ").Append(r.totalSlivers.ToString(inv)).Append("\n"); sb.Append(" ").Append(r.overlapShellPairs.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.uv2DuplicatePairs.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.force3DOverlapCount.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.compositeBrokenCount.ToString(inv)).Append("\n"); + sb.Append(" ").Append(r.severeMismatchCount.ToString(inv)).Append("\n"); sb.Append(" ").Append((r.meanAtlasUtilization * 100f).ToString("F2", inv)).Append("\n"); sb.Append(" ").Append(r.totalMs.ToString(inv)).Append("\n"); sb.Append(" ").Append(r.score.ToString("F2", inv)).Append("\n"); - sb.Append(" ").Append(BuildThumbsCell(r.csvPath, benchmarkReportsRoot)).Append("\n"); + // PNG sibling-of-CSV; link path is relative to index.html. + string indexDir = Path.GetDirectoryName(path) ?? benchmarkReportsRoot; + sb.Append(" ").Append(BuildThumbsCell(r.csvPath, indexDir)).Append("\n"); sb.Append(" \n"); } @@ -620,17 +717,31 @@ internal static void WriteGalleryHtml(string path, List runs, int be /// one anchored thumbnail per PNG, sorted by file name so LOD0 lands /// before LOD1, LOD2, … Returns <em>(no PNG)</em> /// when the directory is missing or empty. + /// PNG dir is always sibling-of-CSV (named <csvBase>_png); + /// the link is computed relative to so the + /// gallery works both during a forward sweep (index.html lives next + /// to PNG dirs) and during rebuild (index.html lives in a separate + /// sweep_recovered_* folder, PNGs stay beside the original CSVs). /// - static string BuildThumbsCell(string csvPath, string benchmarkReportsRoot) + static string BuildThumbsCell(string csvPath, string indexDir) { if (string.IsNullOrEmpty(csvPath)) return "(no PNG)"; string csvBase = Path.GetFileNameWithoutExtension(csvPath); - if (string.IsNullOrEmpty(csvBase) || string.IsNullOrEmpty(benchmarkReportsRoot)) + string csvDir = Path.GetDirectoryName(csvPath); + if (string.IsNullOrEmpty(csvBase) || string.IsNullOrEmpty(csvDir) + || string.IsNullOrEmpty(indexDir)) return "(no PNG)"; - string pngDirName = csvBase + "_png"; - string pngDirAbs = Path.Combine(benchmarkReportsRoot, pngDirName); - if (!Directory.Exists(pngDirAbs)) return "(no PNG)"; + // New layout (post path-length fix): PNG sibling of CSV in a + // short fixed subfolder "uv2_png". Legacy layout used + // "{csvBase}_png" — keep that as a fallback so already- + // recorded sweeps still surface their thumbnails. + string pngDirAbs = Path.Combine(csvDir, "uv2_png"); + if (!Directory.Exists(pngDirAbs)) + { + pngDirAbs = Path.Combine(csvDir, csvBase + "_png"); + if (!Directory.Exists(pngDirAbs)) return "(no PNG)"; + } string[] pngs; try { pngs = Directory.GetFiles(pngDirAbs, "*.png"); } @@ -644,9 +755,7 @@ static string BuildThumbsCell(string csvPath, string benchmarkReportsRoot) foreach (string pngAbs in pngs) { string fileName = Path.GetFileName(pngAbs); - // Sweep dir is sibling of the PNG dir under BenchmarkReports/, - // so "../_png/" is the stable relative link. - string rel = "../" + pngDirName + "/" + fileName; + string rel = MakeRelativePath(indexDir, pngAbs); string label = ExtractLodLabel(fileName); sb.Append("
"); sb.Append(""); @@ -660,6 +769,35 @@ static string BuildThumbsCell(string csvPath, string benchmarkReportsRoot) return sb.ToString(); } + /// POSIX-style relative path from + /// to . Works for any layout (nested, + /// sibling, ancestor) without depending on .NET 5+'s + /// Path.GetRelativePath. Used so the gallery HTML's thumbnail + /// links resolve no matter how the index file is positioned + /// relative to the PNG dirs (forward sweep: side-by-side; rebuild: + /// recovered dir + original sweep dir). + static string MakeRelativePath(string fromDir, string toPath) + { + try + { + string fromFull = Path.GetFullPath(fromDir); + if (!fromFull.EndsWith(Path.DirectorySeparatorChar.ToString(), + StringComparison.Ordinal)) + fromFull += Path.DirectorySeparatorChar; + var fromUri = new Uri(fromFull); + var toUri = new Uri(Path.GetFullPath(toPath)); + string rel = Uri.UnescapeDataString( + fromUri.MakeRelativeUri(toUri).ToString()); + return rel.Replace('\\', '/'); + } + catch + { + // Fall back to absolute path; not great but the gallery + // won't open on a different machine — acceptable degrade. + return toPath.Replace('\\', '/'); + } + } + /// /// Pull the "LOD<N>" token out of a PNG file name like /// Wooden_Box_Long_LOD0_uv2.png. Falls back to the whole base @@ -717,6 +855,13 @@ static string BuildRecommendation(List runs, int bestIdx) if (r.config.atlasRes != w.config.atlasRes) continue; if (r.config.shellPad != w.config.shellPad) continue; if (r.config.borderPad != w.config.borderPad) continue; + // All non-ARAP axes must match exactly so the score delta + // isolates the ARAP toggle. Without this guard the "ARAP + // improved / did not improve" sentence in winner.json can be + // computed against a run that also differs in oversample or + // symSplit mode — a confounded comparison. + if (r.config.internalOversample != w.config.internalOversample) continue; + if (r.config.symSplitMode != w.config.symSplitMode) continue; // Stretch threshold is irrelevant when ARAP is off — match the // winner's threshold on the ON side and ignore it on the OFF // side. Either way, the pair is "this config with ARAP toggled". @@ -761,8 +906,14 @@ internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) return null; } + // Per-cell CSVs used to live at BenchmarkReports/ top level, + // but after the OutputDirectoryOverride redirect they're + // emitted inside sweep_/ (or bench_//legacy/ + // for the unified benchmark). Walk recursively so recovery + // works whether the operator points at the top-level reports + // root, a specific sweep run, or a unified bench case. string[] csvFiles; - try { csvFiles = Directory.GetFiles(benchmarkReportsRoot, "*.csv", SearchOption.TopDirectoryOnly); } + try { csvFiles = Directory.GetFiles(benchmarkReportsRoot, "*.csv", SearchOption.AllDirectories); } catch (Exception ex) { UvtLog.Error(UvtLog.Category.Benchmark, @@ -780,9 +931,14 @@ internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) // label (e.g. 1.50 → "1p50") because Sanitize() collapses '.' to // '_'. We split it back into a float here. The optional // `(?:_asp[01])?` tail keeps old per-cell CSVs from the era of the - // removed global-aspect normalize pass parseable. + // removed global-aspect normalize pass parseable. The optional + // `_os(\d+)_sym(legacy|adaptive)` tail is the new axis pair added + // alongside arapIterations / stretchThreshold; pre-existing CSVs + // without these tokens default to oversample=4 (current default) + // and symMode=LegacyFixed (current default). var rx = new System.Text.RegularExpressions.Regex( - @"_sweep_res(\d+)_pad(\d+)_bdr(\d+)_arap(\d+)_stretch(\d+)p(\d+)(?:_asp[01])?_", + @"_sweep_res(\d+)_pad(\d+)_bdr(\d+)_arap(\d+)_stretch(\d+)p(\d+)" + + @"(?:_asp[01])?(?:_os(\d+))?(?:_sym(legacy|adaptive))?_", System.Text.RegularExpressions.RegexOptions.Compiled); var matched = new List<(string path, CellConfig cfg, DateTime mtime)>(); @@ -799,6 +955,16 @@ internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) string stretchStr = m.Groups[5].Value + "." + m.Groups[6].Value; if (!float.TryParse(stretchStr, NumberStyles.Float, CultureInfo.InvariantCulture, out float stretchThr)) stretchThr = 1.5f; + int oversample = 4; + if (m.Groups[7].Success + && int.TryParse(m.Groups[7].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedOs) + && parsedOs > 0) + { + oversample = parsedOs; + } + var symMode = SymmetrySplitShells.ThresholdMode.LegacyFixed; + if (m.Groups[8].Success && m.Groups[8].Value == "adaptive") + symMode = SymmetrySplitShells.ThresholdMode.Adaptive; DateTime mtime; try { mtime = File.GetLastWriteTimeUtc(csv); } @@ -806,12 +972,14 @@ internal static string RebuildFromExistingCsvs(string benchmarkReportsRoot) matched.Add((csv, new CellConfig { - atlasRes = res, - shellPad = pad, - borderPad = bdr, - arapEnabled = arapIters > 0, - arapIterations = arapIters, - stretchThreshold = stretchThr, + atlasRes = res, + shellPad = pad, + borderPad = bdr, + arapEnabled = arapIters > 0, + arapIterations = arapIters, + stretchThreshold = stretchThr, + internalOversample = oversample, + symSplitMode = symMode, }, mtime)); } @@ -948,5 +1116,281 @@ static string JsonString(string s) sb.Append('"'); return sb.ToString(); } + + // ───────────────────────────────────────────────────────────── + // Provenance manifest + ZIP archive + // ───────────────────────────────────────────────────────────── + + /// + /// Identifying metadata recorded alongside a sweep so a CSV looked at + /// six months from now is still traceable: which package version, which + /// commit, which Unity, which host, which matrix produced it. + /// + internal struct SweepManifest + { + public string sweepDir; // absolute path + public string sweepStamp; // 20260514_113158_083 — matches dir name + public string packageName; + public string packageVersion; + public string gitSha; + public string gitBranch; + public bool gitDirty; + public string unityVersion; + public string platform; + public string hostUser; + public string hostMachine; + public string hostOs; + public string processor; + public int cellCount; + public int caseCount; + public string sweepLabel; // single-model lodGroup name OR multi-case suite name + public TestSuiteAsset.SweepMatrix matrix; + public List caseLabels; // null for single-model run + } + + /// + /// Write manifest.json into the sweep directory with package / + /// Unity / host metadata so old sweep archives can be diff'd against + /// new ones without ambiguity about what produced them. + /// + internal static void WriteManifest(string sweepDir, SweepManifest m) + { + if (string.IsNullOrEmpty(sweepDir) || !Directory.Exists(sweepDir)) return; + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + sb.Append("{\n"); + sb.Append(" \"sweepStamp\": ").Append(JsonString(m.sweepStamp ?? "")).Append(",\n"); + sb.Append(" \"sweepLabel\": ").Append(JsonString(m.sweepLabel ?? "")).Append(",\n"); + sb.Append(" \"createdUtc\": ").Append(JsonString(DateTime.UtcNow.ToString("o", inv))).Append(",\n"); + + sb.Append(" \"package\": {\n"); + sb.Append(" \"name\": ").Append(JsonString(m.packageName ?? "")).Append(",\n"); + sb.Append(" \"version\": ").Append(JsonString(m.packageVersion ?? "")).Append(",\n"); + sb.Append(" \"gitSha\": ").Append(JsonString(m.gitSha ?? "")).Append(",\n"); + sb.Append(" \"gitBranch\": ").Append(JsonString(m.gitBranch ?? "")).Append(",\n"); + sb.Append(" \"gitDirty\": ").Append(m.gitDirty ? "true" : "false").Append("\n"); + sb.Append(" },\n"); + + sb.Append(" \"unity\": {\n"); + sb.Append(" \"version\": ").Append(JsonString(m.unityVersion ?? "")).Append(",\n"); + sb.Append(" \"platform\": ").Append(JsonString(m.platform ?? "")).Append("\n"); + sb.Append(" },\n"); + + sb.Append(" \"host\": {\n"); + sb.Append(" \"user\": ").Append(JsonString(m.hostUser ?? "")).Append(",\n"); + sb.Append(" \"machine\": ").Append(JsonString(m.hostMachine ?? "")).Append(",\n"); + sb.Append(" \"os\": ").Append(JsonString(m.hostOs ?? "")).Append(",\n"); + sb.Append(" \"processor\": ").Append(JsonString(m.processor ?? "")).Append("\n"); + sb.Append(" },\n"); + + sb.Append(" \"sweep\": {\n"); + sb.Append(" \"cellCount\": ").Append(m.cellCount.ToString(inv)).Append(",\n"); + sb.Append(" \"caseCount\": ").Append(m.caseCount.ToString(inv)); + if (m.caseLabels != null && m.caseLabels.Count > 0) + { + sb.Append(",\n \"caseLabels\": ["); + for (int i = 0; i < m.caseLabels.Count; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(JsonString(m.caseLabels[i] ?? "")); + } + sb.Append("]"); + } + if (m.matrix != null) + { + sb.Append(",\n \"matrix\": {\n"); + AppendIntArray(sb, " ", "atlasResolutions", m.matrix.atlasResolutions); sb.Append(",\n"); + AppendIntArray(sb, " ", "shellPaddingPxVariants", m.matrix.shellPaddingPxVariants); sb.Append(",\n"); + AppendIntArray(sb, " ", "borderPaddingPxVariants", m.matrix.borderPaddingPxVariants); sb.Append(",\n"); + AppendIntArray(sb, " ", "arapIterationsVariants", m.matrix.arapIterationsVariants); sb.Append(",\n"); + AppendFloatArray(sb, " ", "stretchThresholdVariants",m.matrix.stretchThresholdVariants); sb.Append(",\n"); + AppendIntArray(sb, " ", "internalOversampleVariants",m.matrix.internalOversampleVariants);sb.Append(",\n"); + sb.Append(" \"symSplitThresholdModeVariants\": ["); + if (m.matrix.symSplitThresholdModeVariants != null) + { + for (int i = 0; i < m.matrix.symSplitThresholdModeVariants.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(JsonString(m.matrix.symSplitThresholdModeVariants[i].ToString())); + } + } + sb.Append("]\n }"); + } + sb.Append(",\n \"scoringWeights\": {\n"); + sb.Append(" \"atlasUtilization\": ").Append(kWeightUtilization.ToString("R", inv)).Append(",\n"); + sb.Append(" \"sliver\": ").Append(kPenaltySliver.ToString("R", inv)).Append(",\n"); + sb.Append(" \"overlap\": ").Append(kPenaltyOverlap.ToString("R", inv)).Append(",\n"); + sb.Append(" \"dupUv2\": ").Append(kPenaltyDupUv2.ToString("R", inv)).Append(",\n"); + sb.Append(" \"force3DOverlap\": ").Append(kPenaltyForce3DOL.ToString("R", inv)).Append(",\n"); + sb.Append(" \"compositeBroken\": ").Append(kPenaltyCompBroken.ToString("R", inv)).Append(",\n"); + sb.Append(" \"severeMismatch\": ").Append(kPenaltySevere.ToString("R", inv)).Append(",\n"); + sb.Append(" \"ms\": ").Append(kPenaltyMs.ToString("R", inv)).Append(",\n"); + sb.Append(" \"resolution\": ").Append(kPenaltyResolution.ToString("R", inv)).Append("\n"); + sb.Append(" }\n"); + sb.Append(" }\n"); + sb.Append("}\n"); + + string path = Path.Combine(sweepDir, "manifest.json"); + try + { + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + UvtLog.Info(UvtLog.Category.Benchmark, $"[Sweep] manifest → {path}"); + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, $"[Sweep] manifest write failed: {ex.Message}"); + } + } + + static void AppendIntArray(StringBuilder sb, string indent, string key, int[] arr) + { + sb.Append(indent).Append('"').Append(key).Append("\": ["); + if (arr != null) + { + var inv = CultureInfo.InvariantCulture; + for (int i = 0; i < arr.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(arr[i].ToString(inv)); + } + } + sb.Append("]"); + } + static void AppendFloatArray(StringBuilder sb, string indent, string key, float[] arr) + { + sb.Append(indent).Append('"').Append(key).Append("\": ["); + if (arr != null) + { + var inv = CultureInfo.InvariantCulture; + for (int i = 0; i < arr.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(arr[i].ToString("R", inv)); + } + } + sb.Append("]"); + } + + /// + /// Resolve UPM package + git provenance for the current build. Falls + /// back gracefully when git is unavailable (e.g. asset-store install or + /// CI runner without .git) so sweep continues to write a manifest with + /// whatever fields could be determined. + /// + internal static (string pkgName, string pkgVersion, string gitSha, string gitBranch, bool gitDirty) + ResolvePackageProvenance() + { + string pkgName = "", pkgVersion = ""; + try + { + var info = PackageInfo.FindForAssembly(typeof(BenchmarkSweep).Assembly); + if (info != null) { pkgName = info.name ?? ""; pkgVersion = info.version ?? ""; } + } + catch (Exception ex) + { + UvtLog.Verbose(UvtLog.Category.Benchmark, $"[Sweep] PackageInfo failed: {ex.Message}"); + } + + // Best-effort git provenance. Runs `git rev-parse HEAD` etc. via + // System.Diagnostics.Process. Anything that throws → blank field; + // we never want manifest writing to take down a sweep. + string sha = TryRunGit("rev-parse HEAD"); + string branch = TryRunGit("rev-parse --abbrev-ref HEAD"); + string status = TryRunGit("status --porcelain"); + bool dirty = !string.IsNullOrEmpty(status); + + return (pkgName, pkgVersion, sha, branch, dirty); + } + + static string TryRunGit(string args) + { + try + { + string repoRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + // Resolve the package's actual source dir — manifests should + // reflect the package commit, not the consuming project. For + // local file: installs this is the package folder; for git URL + // installs Library/PackageCache/@ doesn't have .git + // so commands return blank, which is fine. + string pkgDir = null; + try + { + var info = PackageInfo.FindForAssembly(typeof(BenchmarkSweep).Assembly); + if (info != null && !string.IsNullOrEmpty(info.resolvedPath)) + pkgDir = info.resolvedPath; + } + catch { /* fall through */ } + string workDir = !string.IsNullOrEmpty(pkgDir) ? pkgDir : repoRoot; + + var psi = new System.Diagnostics.ProcessStartInfo("git", args) + { + WorkingDirectory = workDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return ""; + // Drain stdout/stderr asynchronously into builders BEFORE + // WaitForExit — synchronous ReadToEnd() blocks until the + // pipe is closed, so a stalled git would deadlock the + // 2-second timeout we install below. With BeginOutputReadLine + // the timeout actually protects this path: if the process + // doesn't exit in 2s we kill it and return whatever we got. + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + p.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + if (!p.WaitForExit(2000)) + { + try { p.Kill(); } catch { /* best-effort */ } + return ""; + } + return stdout.ToString().Trim(); + } + catch + { + return ""; + } + } + + /// + /// Snapshot the just-written sweep directory into a single .zip under + /// BenchmarkReports/Archive/<sweepDirName>.zip so old runs + /// stay organised even after dozens of new sweeps accumulate. The zip + /// is non-destructive — the source directory is left in place so the + /// operator can keep iterating; an external sync target (Drive, Dropbox) + /// can mirror the Archive/ folder. Returns the zip path or null on + /// failure. Failures only warn, never throw — losing the archive is + /// strictly worse than losing the manifest only. + /// + internal static string ArchiveSweep(string sweepDir) + { + if (string.IsNullOrEmpty(sweepDir) || !Directory.Exists(sweepDir)) return null; + try + { + string benchRoot = Directory.GetParent(sweepDir)?.FullName; + if (string.IsNullOrEmpty(benchRoot)) return null; + string archiveDir = Path.Combine(benchRoot, "Archive"); + Directory.CreateDirectory(archiveDir); + string zipName = Path.GetFileName(sweepDir) + ".zip"; + string zipPath = Path.Combine(archiveDir, zipName); + // Overwrite any prior zip for this stamp (sweep dir may have + // been re-aggregated incrementally during a long run). + if (File.Exists(zipPath)) File.Delete(zipPath); + ZipFile.CreateFromDirectory(sweepDir, zipPath, CompressionLevel.Optimal, + includeBaseDirectory: true); + UvtLog.Info(UvtLog.Category.Benchmark, $"[Sweep] archived → {zipPath}"); + return zipPath; + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, $"[Sweep] archive failed: {ex.Message}"); + return null; + } + } } } diff --git a/Editor/CheckerTexturePreview.cs b/Editor/CheckerTexturePreview.cs index e6c89354..51feda7b 100644 --- a/Editor/CheckerTexturePreview.cs +++ b/Editor/CheckerTexturePreview.cs @@ -77,7 +77,7 @@ static void RestoreAll() public static class CheckerTexturePreview { // ── Shader name constants for preview material detection ── - internal const string ToolShaderPrefix = "Hidden/LightmapUvTool/"; + internal const string ToolShaderPrefix = "Hidden/UnityMeshLab/"; internal const string VertexColorShader = "Hidden/Internal-Colored"; /// @@ -201,10 +201,10 @@ static void EnsureAssets() if (checkerMat == null) { - var sh = Shader.Find("Hidden/LightmapUvTool/CheckerUV2"); + var sh = Shader.Find("Hidden/UnityMeshLab/CheckerUV2"); if (sh == null) { - UvtLog.Error("[Checker] Shader 'Hidden/LightmapUvTool/CheckerUV2' not found"); + UvtLog.Error("[Checker] Shader 'Hidden/UnityMeshLab/CheckerUV2' not found"); return; } checkerMat = new Material(sh) { hideFlags = HideFlags.HideAndDontSave }; diff --git a/Editor/Framework/UvCanvasView.cs b/Editor/Framework/UvCanvasView.cs index d844d0c8..39c1585a 100644 --- a/Editor/Framework/UvCanvasView.cs +++ b/Editor/Framework/UvCanvasView.cs @@ -131,12 +131,12 @@ public void Init() GlMat.SetInt("_Cull", (int)CullMode.Off); GlMat.SetInt("_ZWrite", 0); - var texShader = Shader.Find("Hidden/LightmapUvTool/TintedTexture"); + var texShader = Shader.Find("Hidden/UnityMeshLab/TintedTexture"); if (texShader == null) texShader = Shader.Find("Unlit/Transparent"); if (texShader != null) TexMat = new Material(texShader) { hideFlags = HideFlags.HideAndDontSave }; - var spotShader = Shader.Find("Hidden/LightmapUvTool/SpotProjection"); + var spotShader = Shader.Find("Hidden/UnityMeshLab/SpotProjection"); if (spotShader != null) SpotMat = new Material(spotShader) { hideFlags = HideFlags.HideAndDontSave }; diff --git a/Editor/Framework/UvToolHub.cs b/Editor/Framework/UvToolHub.cs index 55d0fa29..25718ef0 100644 --- a/Editor/Framework/UvToolHub.cs +++ b/Editor/Framework/UvToolHub.cs @@ -1000,7 +1000,7 @@ void DrawSidebarFooter() EditorGUILayout.Space(4); var bg = GUI.backgroundColor; -#if LIGHTMAP_UV_TOOL_FBX_EXPORTER +#if UNITY_MESH_LAB_FBX_EXPORTER if (!PostprocessorDefineManager.IsEnabled()) { EditorGUILayout.HelpBox( diff --git a/Editor/GroupedShellTransfer.cs b/Editor/GroupedShellTransfer.cs index 297ae1b9..051ddd39 100644 --- a/Editor/GroupedShellTransfer.cs +++ b/Editor/GroupedShellTransfer.cs @@ -101,6 +101,22 @@ public class TransferResult public int shellsRejected; // shells where UV2 was not written (too many issues) public int shellsOverlapFixed; // force3D shells relocated due to UV2 overlap + // ─── Visual-defect counters (added for sweep visibility) ─── + // Captures failure modes that the existing solid-pipeline metrics + // (shellsRejected, overlapShellPairs, coverage) miss but the user + // sees on the rendered atlas. See TRANSFER_BENCHMARK.md. + /// Pairs of target shells whose quantised UV2 fingerprint hash matches. + /// Non-zero = two distinct 3D instances bake onto the same atlas region + /// (lightmap data shared between unrelated geometry). + public int uv2DuplicatePairs; + /// Target shells whose Phase 3 composite UV2 spilled out of the matched + /// source UV2 region (compArea > 2× srcArea) and were forced back to a + /// single-source fallback. Signals a Phase 2 matching miss. + public int compositeBrokenCount; + /// Target shells whose chosen source is >10% of mesh diagonal away + /// in 3D — almost always a wrong-source assignment by Phase 2. + public int severeMismatchCount; + // ─── Topology enforcement snapshot (per-target, captured by Transfer) ─── // Copied from LastTopology* immediately after EnforceShellTopologyOnUv2 // so multi-mesh runs don't all read the final target's global values. @@ -2366,6 +2382,7 @@ static TransferResult TransferCore( if (bestSrcUv2Area > 1e-8f && compArea > bestSrcUv2Area * 2.0f) { compositeSpatiallyBroken = true; + result.compositeBrokenCount++; UvtLog.Info($"[GroupedTransfer] t{tsi}: composite spatially broken " + $"(compArea={compArea:F6} > 2×srcArea={bestSrcUv2Area:F6}), " + $"falling back to single-source"); @@ -3537,9 +3554,14 @@ static TransferResult TransferCore( UvtLog.Info(sb.ToString()); } - // Per-shell UV2 fingerprint: hash of UV2 values for cross-branch comparison. - // Logs centroid + hash so users can diff logs between branches to find - // which specific shells produce different UV2. + // Per-shell UV2 fingerprint: hash of UV2 values for cross-branch + // comparison. Logs centroid + hash so users can diff logs between + // branches to find which specific shells produce different UV2. + // Computed pre-topology so the values match the raw Phase 3 output; + // the post-topology pass below recomputes duplicate-pair counts on + // the FINAL UV2 (topology can shift a few verts and break the early + // hash, but it preserves the gross shell placement that this log + // line is useful for diffing). { var fpSb = new System.Text.StringBuilder(); fpSb.Append($"[GroupedTransfer] UV2 fingerprint '{targetMeshName}':"); @@ -3578,6 +3600,81 @@ static TransferResult TransferCore( result.topologyFixed = LastTopologyFixed; result.topologyCapHit = LastTopologyCapHit; + // ── Visual-defect counters (computed on the FINAL UV2) ── + // Post-topology so duplicate-pair detection runs on the bytes that + // will actually be written to mesh.uv2 — topology can nudge verts + // and an early hash would be stale. Severe-mismatch also lives + // here so all sweep-scoring counters share one consistent snapshot + // taken after Phase 2 and Phase 3 are fully done. + { + // (A) UV2 duplicate-pair count — combinatorial per hash group. + // For a group of k shells with identical hash, the number + // of colliding pairs is k*(k-1)/2. Previously the code + // counted "extras beyond first" (k-1), under-reporting + // groups of 3+ shells. Rejected/Unmatched are excluded + // so the "empty hash" at (0,0) doesn't inflate the count. + var hashGroupSize = new Dictionary(tgtShells.Count); + for (int tsi2 = 0; tsi2 < tgtShells.Count; tsi2++) + { + if (tsi2 < result.targetShellStatus.Length) + { + var status = result.targetShellStatus[tsi2]; + if (status == ShellStatus.Rejected || status == ShellStatus.Unmatched) + continue; + } + var shell = tgtShells[tsi2]; + // Hash the SET of quantized UV2 positions, not the order + // we happen to visit verts in. vertexIndices was populated + // from a HashSet upstream, so two shells with the + // same UV2 layout but different HashSet iteration order + // would otherwise hash differently and miss the duplicate. + var quantized = new List<(int qx, int qy)>(shell.vertexIndices.Count); + foreach (int vi in shell.vertexIndices) + { + if (vi >= result.uv2.Length) continue; + var uv = result.uv2[vi]; + quantized.Add((Mathf.RoundToInt(uv.x * 100000f), + Mathf.RoundToInt(uv.y * 100000f))); + } + if (quantized.Count == 0) continue; + quantized.Sort((a, b) => a.qx != b.qx ? a.qx.CompareTo(b.qx) + : a.qy.CompareTo(b.qy)); + uint hash = 2166136261u; + foreach (var p in quantized) + { + unchecked + { + hash = (hash ^ (uint)p.qx) * 16777619u; + hash = (hash ^ (uint)p.qy) * 16777619u; + } + } + hashGroupSize.TryGetValue(hash, out int k); + hashGroupSize[hash] = k + 1; + } + int dupPairs = 0; + foreach (var kv in hashGroupSize) + { + int k = kv.Value; + if (k >= 2) dupPairs += k * (k - 1) / 2; + } + result.uv2DuplicatePairs = dupPairs; + + // (B) Severe-mismatch count — target shells whose chosen source + // is >10% of mesh diagonal away in 3D. Computed late so the + // final post-dedup / post-rematch source assignments and + // match distances are reflected, not a mid-Phase-2 snapshot. + float severeThresholdSq = (meshDiagonal * 0.1f) * (meshDiagonal * 0.1f); + int severe = 0; + for (int tsi = 0; tsi < tgtShells.Count; tsi++) + { + if (result.targetShellToSourceShell[tsi] < 0) continue; + float dsq = result.targetShellMatchDistSqr[tsi]; + if (dsq >= float.MaxValue || float.IsInfinity(dsq)) continue; + if (dsq > severeThresholdSq) severe++; + } + result.severeMismatchCount = severe; + } + // ── Collapse-to-line diagnostic ── // Detect target shells whose UV2 layout has collapsed to a line // (one bbox dim near zero) or extreme sliver (UV aspect ≫ 3D @@ -3667,6 +3764,80 @@ static TransferResult TransferCore( } result.matchHints = matchHints; + // ── Per-target Transfer summary (TransferDiag category) ── + // Single concise line + histogram per target LOD so the user can + // run the identity sanity test and per-LOD ratio sweep from + // TRANSFER_LOD_QUALITY_PLAN.md without trawling verbose logs. + // Emitted at Info level under UvtLog.Category.TransferDiag so the + // existing Log filters toggle lets the user gate it independently + // of the noisy per-shell Match/Topology output. + if (UvtLog.Current >= UvtLog.Level.Info + && UvtLog.IsCategoryEnabled(UvtLog.Category.TransferDiag)) + { + int accepted = 0, degraded = 0, poor = 0, rejected = 0, unmatched = 0; + if (result.targetShellStatus != null) + { + for (int i = 0; i < result.targetShellStatus.Length; i++) + { + switch (result.targetShellStatus[i]) + { + case ShellStatus.Accepted: accepted++; break; + case ShellStatus.Degraded: degraded++; break; + case ShellStatus.Poor: poor++; break; + case ShellStatus.Rejected: rejected++; break; + case ShellStatus.Unmatched: unmatched++; break; + } + } + } + + // Mean / max 3D centroid match distance over matched shells + double sumDist = 0; float maxDist = 0; int matchedCount = 0; + if (result.targetShellMatchDistSqr != null + && result.targetShellToSourceShell != null) + { + for (int i = 0; i < result.targetShellMatchDistSqr.Length; i++) + { + if (result.targetShellToSourceShell[i] < 0) continue; + float dsq = result.targetShellMatchDistSqr[i]; + if (float.IsInfinity(dsq) || dsq >= float.MaxValue) continue; + float d = Mathf.Sqrt(Mathf.Max(dsq, 0f)); + sumDist += d; + if (d > maxDist) maxDist = d; + matchedCount++; + } + } + float meanDist = matchedCount > 0 ? (float)(sumDist / matchedCount) : 0f; + + int methodInterp = 0, methodXform = 0, methodMerged = 0; + if (result.targetShellMethod != null) + { + for (int i = 0; i < result.targetShellMethod.Length; i++) + { + switch (result.targetShellMethod[i]) + { + case 0: methodInterp++; break; + case 1: methodXform++; break; + case 2: methodMerged++; break; + } + } + } + + UvtLog.Info(UvtLog.Category.TransferDiag, + $"'{targetMeshName}' ← '{sourceMeshName}': " + + $"shells src={srcShells.Count} tgt={tgtShells.Count} | " + + $"matched={result.shellsMatched} unmatched={unmatched} " + + $"rejected={result.shellsRejected} | " + + $"status A={accepted}/D={degraded}/P={poor}/R={rejected}/U={unmatched} | " + + $"method interp={methodInterp} xform={methodXform} merged={methodMerged} | " + + $"fragMerged={result.fragmentsMerged} dedupConf={result.dedupConflicts} " + + $"overlapFixed={result.shellsOverlapFixed} consistFix={result.consistencyCorrected} | " + + $"DUP={result.uv2DuplicatePairs} COMP={result.compositeBrokenCount} SEVERE={result.severeMismatchCount} | " + + $"matchDist mean={meanDist:F4} max={maxDist:F4} | " + + $"topo iters={result.topologyIterations} fixed={result.topologyFixed} " + + $"capHit={(result.topologyCapHit ? 1 : 0)} | " + + $"verts={result.verticesTransferred}/{result.verticesTotal}"); + } + return result; } diff --git a/Editor/HierarchicalApply.cs b/Editor/HierarchicalApply.cs new file mode 100644 index 00000000..bf87318f --- /dev/null +++ b/Editor/HierarchicalApply.cs @@ -0,0 +1,154 @@ +// HierarchicalApply.cs — Stage 6 driver: take the per-LOD Result the +// hier pipeline produced, clone the LOD meshes with the new uv2 baked +// in (BuildFinalMeshes on HierarchicalRepack), then swap the clones +// into the LODGroup's MeshFilters under one Undo group so a single +// Ctrl+Z reverts the whole apply. +// +// Mesh-mutation safety rules (per unity-undo-prefab-safety): +// 1. New Mesh() — register with Undo.RegisterCreatedObjectUndo after +// the assignment, so Undo destroys the clone on revert. +// 2. mf.sharedMesh = clone — Undo.RecordObject(mf, …) BEFORE the +// mutation. Never touch mf.mesh in edit mode (clones + leaks). +// 3. If MeshFilter is on a prefab instance, follow up with +// PrefabUtility.RecordPrefabInstancePropertyModifications(mf). +// 4. Group everything via Undo.IncrementCurrentGroup + +// CollapseUndoOperations so a single Ctrl+Z reverses the whole +// apply. + +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class HierarchicalApply + { + const string kMenuPath = "Mesh Lab/Hier/Apply UV2 to Selected LODGroup"; + + [MenuItem(kMenuPath)] + static void ApplyToSelectedLodGroup() + { + var sel = Selection.activeGameObject; + if (sel == null) + { + EditorUtility.DisplayDialog("Hier UV2", + "Select a GameObject that's part of a LODGroup, then run the menu again.", + "OK"); + return; + } + var lg = sel.GetComponentInParent(); + if (lg == null) + { + EditorUtility.DisplayDialog("Hier UV2", + $"'{sel.name}' has no LODGroup in its parent chain.", + "OK"); + return; + } + + var opts = HierarchicalRepack.Options.Default; + var result = HierarchicalRepack.Build(lg, opts); + if (!string.IsNullOrEmpty(result.error)) + { + EditorUtility.DisplayDialog("Hier UV2", + $"Build failed on '{lg.name}':\n{result.error}", + "OK"); + return; + } + + HierarchicalRepack.BuildFinalMeshes(lg, result); + int applyCount = ApplyFinalMeshesToLodGroup(lg, result); + + if (applyCount == 0) + { + EditorUtility.DisplayDialog("Hier UV2", + "No final meshes were produced for any LOD on this group.\n" + + "Check the Console for stage-specific warnings.", + "OK"); + return; + } + + // Stage E3 headline so the operator sees atlas quality without + // digging through the Console (full table: stage_e_metrics.csv + // in benchmark runs, per-LOD lines in the log). + string e3Note = ""; + if (result.stageEMetrics != null) + { + int overlapPx = 0, unplaced = 0, misaligned = 0; + foreach (var m in result.stageEMetrics) + { + overlapPx += m.overlapTexels; + unplaced += m.unplacedFaces; + misaligned += m.misalignedGroups; + } + e3Note = $"\nAtlas check: overlap {overlapPx} texel(s), " + + $"unplaced faces {unplaced}, misaligned domains {misaligned}." + + (unplaced > 0 || misaligned > 0 + ? "\nWARNING: non-zero defects — see Console for per-LOD detail." + : ""); + } + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Applied UV2 to '{lg.name}' ({applyCount} LODs). Ctrl+Z to revert."); + EditorUtility.DisplayDialog("Hier UV2", + $"Applied hierarchical UV2 to {applyCount} LOD(s) on '{lg.name}'.\n" + + "Use Ctrl+Z to revert." + e3Note, + "OK"); + } + + [MenuItem(kMenuPath, true)] + static bool ApplyToSelectedLodGroupValidate() + { + var sel = Selection.activeGameObject; + return sel != null && sel.GetComponentInParent() != null; + } + + /// Swap each fine LOD's MeshFilter.sharedMesh for the + /// matching result.finalMeshes[li]. All swaps land in one + /// Undo group named after the LODGroup so a single Ctrl+Z reverts + /// the apply. Returns the count of LODs actually swapped. + internal static int ApplyFinalMeshesToLodGroup(LODGroup lg, + HierarchicalRepack.Result result) + { + if (lg == null || result.finalMeshes == null) return 0; + var lods = lg.GetLODs(); + + Undo.IncrementCurrentGroup(); + int undoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName($"Apply Hier UV2 ({lg.name})"); + + int applied = 0; + try + { + for (int li = 0; li < lods.Length && li < result.finalMeshes.Length; li++) + { + var clone = result.finalMeshes[li]; + if (clone == null) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + if (mf == null) continue; + + // Register the new mesh first — RegisterCreatedObjectUndo + // attaches it to the current undo group so Ctrl+Z + // destroys the clone alongside reverting the MF swap. + Undo.RegisterCreatedObjectUndo(clone, "Apply Hier UV2 (mesh)"); + + // Snapshot the MeshFilter's state BEFORE mutation so + // Undo restores the original sharedMesh reference. + Undo.RecordObject(mf, "Apply Hier UV2 (filter)"); + mf.sharedMesh = clone; + + // Persist as a prefab-instance override when relevant. + if (PrefabUtility.IsPartOfPrefabInstance(mf)) + PrefabUtility.RecordPrefabInstancePropertyModifications(mf); + + applied++; + } + } + finally + { + Undo.CollapseUndoOperations(undoGroup); + } + return applied; + } + } +} diff --git a/Editor/HierarchicalApply.cs.meta b/Editor/HierarchicalApply.cs.meta new file mode 100644 index 00000000..3983fe30 --- /dev/null +++ b/Editor/HierarchicalApply.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a1f01860f3746b8b832c1bc5b570808 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/HierarchicalDiag.cs b/Editor/HierarchicalDiag.cs new file mode 100644 index 00000000..e309c1f1 --- /dev/null +++ b/Editor/HierarchicalDiag.cs @@ -0,0 +1,737 @@ +// HierarchicalDiag.cs — Editor-only diagnostic probe for the inverse-hierarchical +// UV2 transfer concept. Read-only — does NOT modify any mesh, asset, or sidecar. +// +// For every face on every non-deepest LOD of the active LODGroup it finds the +// best-matching parent on the deepest LOD. v2 reports two correspondence +// modes for the same input so the operator can compare: +// +// 1. face-level (naive, v1 baseline): nearest deepest-LOD triangle by 3D +// centroid → angle between face normals + area-ratio against THAT +// triangle. Suffers from tessellation mismatch — a single front-face +// of LOD0 vs a 2-tri box-face of LOD3 trivially gives ratio>1 and +// arbitrary angle picks. +// +// 2. shell-level (v2 main signal): extract 3D shells on the deepest LOD +// via face-adjacency + normal threshold (≤30°), then for every fine +// face pick the K=10 nearest shells by centroid and choose the one +// with the smallest normal angle. Reported angle is to the shell's +// area-weighted dominant normal; reported area-ratio is against +// TOTAL shell area (not a single triangle). This matches what the +// real HierarchicalRepack would do (LOD3 packs at shell granularity, +// not at triangle granularity). +// +// CSV gets both columns side-by-side. Console summary reports both. The +// shell-level numbers are the ones that map to the GO/STOP decision in +// the plan; face-level is kept as a sanity reference. + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class HierarchicalDiag + { + const string MenuPath = "Mesh Lab/Diag/Hierarchical Containment Probe"; + + // θ thresholds in degrees. ~71% area is retained under orthographic + // projection at θ=45°, ~50% at θ=60°. Below 30° projection is near + // isometric — that band is the "safe" containment zone. + static readonly float[] kThetaSamples = { 15f, 30f, 45f, 60f, 90f }; + + // Adjacency-based shell extraction on the deepest LOD: two adjacent + // faces belong to the same shell if their normals differ by less + // than this. Matches xatlas hard-edge analysis convention. + const float kShellNormalThresholdDeg = 30f; + + // K-nearest shells to consider when picking the best parent for a + // fine face. The 1st-nearest by centroid often isn't the best by + // angle on tessellated / curved deepest LODs — searching K=10 + // gives near-optimal results in our prior debugging without + // performance impact (sub-millisecond per fine face). + const int kShellSearchK = 10; + + [MenuItem(MenuPath, true)] + static bool Validate() + { + var go = Selection.activeGameObject; + return go != null && go.GetComponentInParent() != null; + } + + [MenuItem(MenuPath)] + static void RunProbe() + { + var lg = Selection.activeGameObject?.GetComponentInParent(); + if (lg == null) + { + EditorUtility.DisplayDialog("Hierarchical Containment Probe", + "Select a GameObject under a LODGroup first.", "OK"); + return; + } + try + { + string path = ProbeLodGroup(lg); + EditorUtility.DisplayDialog("Hierarchical Containment Probe", + $"Probe complete.\n\nCSV: {path}\n\nSee console for summary.", "OK"); + } + catch (System.Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[HierDiag] Probe failed: {ex.Message}\n{ex.StackTrace}"); + EditorUtility.DisplayDialog("Hierarchical Containment Probe", + $"Probe failed:\n{ex.Message}", "OK"); + } + } + + /// + /// Probe a single LODGroup. Writes a per-face CSV under + /// BenchmarkReports/hierdiag_<ts>_<lgName>.csv and + /// logs a summary block to the console. Returns the CSV path. + /// + // Standalone-menu entry — writes to BenchmarkReports/hierdiag_*.csv. + public static string ProbeLodGroup(LODGroup lg) => ProbeLodGroup(lg, null); + + /// Run the probe and write its CSV. If + /// is non-null the report lands as {outputDir}/hier_probe.csv + /// (used by the unified benchmark orchestrator). Null falls back to the + /// timestamped BenchmarkReports/ layout for standalone runs. + public static string ProbeLodGroup(LODGroup lg, string outputDir) + { + var lods = lg.GetLODs(); + if (lods.Length < 2) + throw new System.InvalidOperationException( + "LODGroup needs at least 2 LOD levels for hierarchical probe."); + int deepestIdx = lods.Length - 1; + + // Group renderers across LODs by MeshGroupKey so each fine renderer + // is paired with its deepest-LOD counterpart even when there are + // many renderers per LOD level (e.g. multi-mesh LODGroups). + var groups = new Dictionary(); + for (int li = 0; li < lods.Length; li++) + { + var rends = lods[li].renderers; + if (rends == null) continue; + foreach (var r in rends) + { + if (r == null) continue; + string key = UvToolContext.ExtractGroupKey(r.name); + if (!groups.TryGetValue(key, out var arr)) + { + arr = new Renderer[lods.Length]; + groups[key] = arr; + } + arr[li] = r; + } + } + + if (groups.Count == 0) + throw new System.InvalidOperationException( + "No renderers found under LODGroup."); + + var allRecords = new List(); + int groupsProbed = 0, groupsSkipped = 0; + foreach (var kv in groups) + { + var arr = kv.Value; + if (arr[deepestIdx] == null) { groupsSkipped++; continue; } + var deepMesh = arr[deepestIdx].GetComponent()?.sharedMesh; + if (deepMesh == null) { groupsSkipped++; continue; } + + // World-space mesh diagonal first so canonicalization (used + // by ExtractShells to dedup seam-split verts) and per-fine + // probing share the same scale reference. + float meshDiag = ComputeMeshDiagonal(deepMesh, arr[deepestIdx].transform); + if (meshDiag < 1e-6f) meshDiag = 1f; // safety: avoid div-by-zero + + var deepFaces = BuildFaceData(deepMesh, arr[deepestIdx].transform); + var deepCanonical = BuildCanonicalTris(deepMesh, arr[deepestIdx].transform, meshDiag); + var deepShells = ExtractShells(deepFaces, deepCanonical, deepMesh.vertexCount); + bool any = false; + for (int li = 0; li < deepestIdx; li++) + { + if (arr[li] == null) continue; + var fineMesh = arr[li].GetComponent()?.sharedMesh; + if (fineMesh == null) continue; + var fineFaces = BuildFaceData(fineMesh, arr[li].transform); + ProbeFineAgainstDeep(kv.Key, li, fineFaces, deepFaces, deepShells, meshDiag, allRecords); + any = true; + } + if (any) groupsProbed++; + } + + string outPath = WriteReport(lg.name, allRecords, outputDir); + LogSummary(lg.name, allRecords, groupsProbed, groupsSkipped, deepestIdx); + return outPath; + } + + struct FaceData + { + public Vector3 centroid; // world-space + public Vector3 normal; // world-space, unit length + public float area; // world-space triangle area + } + + struct ShellData + { + public Vector3 centroid; // area-weighted world-space + public Vector3 dominantNormal; // area-weighted world-space, unit length + public float totalArea; + public int faceCount; + } + + struct FaceProbeRecord + { + public string groupKey; + public int lodIndex; // fine LOD level (0 = LOD0) + public int faceIndex; // index inside fine LOD's tris[] + // Face-level (v1): nearest deepest-LOD triangle + public int parentFaceIndex; + public float faceCentroidDistance; + public float faceAngleDeg; + public float faceAreaRatio; + // Shell-level (v2): best-angle match among K-nearest deepest-LOD shells + public int parentShellIndex; + public float shellCentroidDistance; + public float shellAngleDeg; + public float shellAreaRatio; + // Shell-level (v3): perpendicular distance from fine centroid to + // parent shell's plane, normalized by the deepest-LOD mesh diagonal. + // Distinguishes "fine face is geometrically on top of parent shell" + // (small d_perp, projects cleanly) from "fine face is floating above + // the parent shell" (large d_perp — promotion candidate even if + // angle and ratio both look OK, e.g. a bolt sticking out of a wall + // with normal parallel to the wall but offset 30cm into space). + public float shellPerpDistanceNorm; + public float fineArea; + } + + /// + /// World-space AABB diagonal of . Used as the + /// scale reference for normalizing perpendicular fine→shell distances. + /// + static float ComputeMeshDiagonal(Mesh mesh, Transform xform) + { + var verts = mesh.vertices; + if (verts == null || verts.Length == 0) return 0f; + var p0 = xform.TransformPoint(verts[0]); + Vector3 lo = p0, hi = p0; + for (int i = 1; i < verts.Length; i++) + { + var p = xform.TransformPoint(verts[i]); + lo = Vector3.Min(lo, p); + hi = Vector3.Max(hi, p); + } + return (hi - lo).magnitude; + } + + /// Quantize world-space vertex positions onto a grid + /// (cell = meshDiag × 1e-5) and rewrite the mesh's triangle index + /// array so each corner references its canonical (grid-cell) ID. + /// Mirrors HierarchicalRepack.BuildCanonicalIndices so the + /// probe's shell counts agree with the repack pipeline's — without + /// dedup, Unity UV/normal seam splits fragment continuous surfaces + /// into many single-tri shells and distort the GO/STOP signal. + static int[] BuildCanonicalTris(Mesh mesh, Transform xform, float meshDiag) + { + var localVerts = mesh.vertices; + var tris = mesh.triangles; + int vn = localVerts.Length; + var canonical = new int[vn]; + for (int i = 0; i < vn; i++) canonical[i] = -1; + + float cell = Mathf.Max(meshDiag, 1f) * 1e-5f; + float invCell = 1f / cell; + var grid = new Dictionary<(long, long, long), int>(vn); + int next = 0; + for (int t = 0; t < tris.Length; t++) + { + int vi = tris[t]; + if (canonical[vi] >= 0) continue; + var p = xform.TransformPoint(localVerts[vi]); + var key = ((long)Mathf.Floor(p.x * invCell), + (long)Mathf.Floor(p.y * invCell), + (long)Mathf.Floor(p.z * invCell)); + if (!grid.TryGetValue(key, out int id)) + { + id = next++; + grid[key] = id; + } + canonical[vi] = id; + } + var rewritten = new int[tris.Length]; + for (int t = 0; t < tris.Length; t++) + rewritten[t] = canonical[tris[t]]; + return rewritten; + } + + /// + /// Read mesh.vertices once, transform to world space, derive per-tri + /// centroid/normal/area. Returns a flat array indexed by tri index. + /// + static FaceData[] BuildFaceData(Mesh mesh, Transform xform) + { + var localVerts = mesh.vertices; + var verts = new Vector3[localVerts.Length]; + for (int i = 0; i < localVerts.Length; i++) + verts[i] = xform.TransformPoint(localVerts[i]); + var tris = mesh.triangles; + int n = tris.Length / 3; + var data = new FaceData[n]; + for (int f = 0; f < n; f++) + { + var a = verts[tris[f * 3]]; + var b = verts[tris[f * 3 + 1]]; + var c = verts[tris[f * 3 + 2]]; + data[f].centroid = (a + b + c) / 3f; + var cross = Vector3.Cross(b - a, c - a); + float mag = cross.magnitude; + data[f].normal = mag > 1e-12f ? cross / mag : Vector3.up; + data[f].area = mag * 0.5f; + } + return data; + } + + /// + /// Extract "3D shells" from the deepest LOD: connected groups of faces + /// whose adjacent-pair normal angle is below + /// . Adjacency = shared edge + /// (two vertex indices in common). For each shell, compute area-weighted + /// dominant normal and centroid + total area. The shell index for each + /// face is returned in . + /// must reference position-deduplicated + /// vertex IDs (see ) so seam-split + /// vertices in Unity meshes don't fragment a single physical surface. + /// + static ShellData[] ExtractShells(FaceData[] faces, int[] canonicalTris, int vertexCount) + { + int n = faces.Length; + if (n == 0) return new ShellData[0]; + + // Build edge → list-of-faces map for adjacency. + // Key = (minVi, maxVi) packed as long. + var edgeFaces = new Dictionary>(n * 3); + void AddEdge(int va, int vb, int face) + { + long key = va < vb + ? ((long)va << 32) | (uint)vb + : ((long)vb << 32) | (uint)va; + if (!edgeFaces.TryGetValue(key, out var list)) + { + list = new List(2); + edgeFaces[key] = list; + } + list.Add(face); + } + for (int f = 0; f < n; f++) + { + int v0 = canonicalTris[f * 3], v1 = canonicalTris[f * 3 + 1], v2 = canonicalTris[f * 3 + 2]; + AddEdge(v0, v1, f); AddEdge(v1, v2, f); AddEdge(v2, v0, f); + } + + float thresholdCos = Mathf.Cos(kShellNormalThresholdDeg * Mathf.Deg2Rad); + + // Union-find over faces by adjacency + normal compatibility. + var parent = new int[n]; + for (int i = 0; i < n; i++) parent[i] = i; + int Find(int x) + { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + } + void Union(int a, int b) + { + int ra = Find(a), rb = Find(b); + if (ra != rb) parent[ra] = rb; + } + + foreach (var kv in edgeFaces) + { + var list = kv.Value; + if (list.Count < 2) continue; + for (int i = 0; i < list.Count; i++) + for (int j = i + 1; j < list.Count; j++) + { + float d = Vector3.Dot(faces[list[i]].normal, faces[list[j]].normal); + if (d >= thresholdCos) Union(list[i], list[j]); + } + } + + // Materialise shells. + var shellOf = new int[n]; + var rootToShell = new Dictionary(); + var accumNormal = new List(); + var accumCentroid = new List(); + var accumArea = new List(); + var accumCount = new List(); + for (int f = 0; f < n; f++) + { + int r = Find(f); + if (!rootToShell.TryGetValue(r, out int si)) + { + si = accumNormal.Count; + rootToShell[r] = si; + accumNormal.Add(Vector3.zero); + accumCentroid.Add(Vector3.zero); + accumArea.Add(0f); + accumCount.Add(0); + } + shellOf[f] = si; + float a = faces[f].area; + accumNormal[si] = accumNormal[si] + faces[f].normal * a; + accumCentroid[si] = accumCentroid[si] + faces[f].centroid * a; + accumArea[si] = accumArea[si] + a; + accumCount[si] = accumCount[si] + 1; + } + var shells = new ShellData[accumNormal.Count]; + for (int si = 0; si < shells.Length; si++) + { + float ta = accumArea[si]; + shells[si].totalArea = ta; + shells[si].faceCount = accumCount[si]; + if (ta > 1e-12f) + { + shells[si].centroid = accumCentroid[si] / ta; + var n2 = accumNormal[si] / ta; + float m = n2.magnitude; + shells[si].dominantNormal = m > 1e-12f ? n2 / m : Vector3.up; + } + else + { + shells[si].dominantNormal = Vector3.up; + } + } + return shells; + } + + /// + /// For each fine face: brute-force nearest deepest-LOD triangle (face-level + /// reference), plus K-nearest deepest-LOD shells + best-angle pick + /// (shell-level main signal). O(N×M) per LOD pair — fine for diag on + /// <30k face meshes. + /// + static void ProbeFineAgainstDeep(string groupKey, int lodIndex, + FaceData[] fine, FaceData[] deep, ShellData[] shells, float meshDiagonal, + List sink) + { + if (deep.Length == 0) return; + // Reusable K-nearest buffer for shell search: (distSq, shellIndex). + int K = Mathf.Min(kShellSearchK, shells.Length); + var topShells = new (float dsq, int si)[K]; + + for (int f = 0; f < fine.Length; f++) + { + Vector3 fc = fine[f].centroid; + + // (A) face-level: nearest single deepest-LOD triangle by centroid. + int bestFace = -1; + float bestFaceDistSq = float.MaxValue; + for (int g = 0; g < deep.Length; g++) + { + float dsq = (fc - deep[g].centroid).sqrMagnitude; + if (dsq < bestFaceDistSq) { bestFaceDistSq = dsq; bestFace = g; } + } + + // (B) shell-level: K nearest shells by centroid, then pick the + // one with the smallest angle to the fine face's normal. + // Initialize buffer with +∞. + for (int k = 0; k < K; k++) topShells[k] = (float.MaxValue, -1); + for (int si = 0; si < shells.Length; si++) + { + float dsq = (fc - shells[si].centroid).sqrMagnitude; + // Insertion into sorted top-K (smallest distSq first). + if (dsq >= topShells[K - 1].dsq) continue; + int pos = K - 1; + while (pos > 0 && topShells[pos - 1].dsq > dsq) + { + topShells[pos] = topShells[pos - 1]; + pos--; + } + topShells[pos] = (dsq, si); + } + int bestShell = -1; + float bestShellAngle = float.MaxValue; + float bestShellDistSq = float.MaxValue; + for (int k = 0; k < K; k++) + { + int si = topShells[k].si; + if (si < 0) break; + float dot = Vector3.Dot(fine[f].normal, shells[si].dominantNormal); + if (dot > 1f) dot = 1f; + if (dot < -1f) dot = -1f; + float ang = Mathf.Acos(dot) * Mathf.Rad2Deg; + if (ang < bestShellAngle) + { + bestShellAngle = ang; + bestShell = si; + bestShellDistSq = topShells[k].dsq; + } + } + + // Record both modes. + float faceAngle, faceRatio; + if (bestFace >= 0) + { + float dot = Vector3.Dot(fine[f].normal, deep[bestFace].normal); + if (dot > 1f) dot = 1f; + if (dot < -1f) dot = -1f; + faceAngle = Mathf.Acos(dot) * Mathf.Rad2Deg; + faceRatio = deep[bestFace].area > 1e-12f + ? fine[f].area / deep[bestFace].area + : float.PositiveInfinity; + } + else { faceAngle = 0f; faceRatio = 0f; } + + float shellRatio = (bestShell >= 0 && shells[bestShell].totalArea > 1e-12f) + ? fine[f].area / shells[bestShell].totalArea + : float.PositiveInfinity; + + // Perpendicular distance from fine-face centroid to the parent + // shell's mean plane. Normalized by the deepest-LOD mesh + // diagonal so the threshold is scale-invariant. abs(dot(...)) + // — orientation of the shell normal vs the fine face is + // already captured by the angle metric; what we want here is + // how far ALONG the shell's normal the fine face sits. + float perpNorm = 0f; + if (bestShell >= 0) + { + var s = shells[bestShell]; + float perp = Mathf.Abs(Vector3.Dot(fine[f].centroid - s.centroid, s.dominantNormal)); + perpNorm = perp / meshDiagonal; + } + + sink.Add(new FaceProbeRecord + { + groupKey = groupKey, + lodIndex = lodIndex, + faceIndex = f, + parentFaceIndex = bestFace, + faceCentroidDistance = Mathf.Sqrt(bestFaceDistSq), + faceAngleDeg = faceAngle, + faceAreaRatio = faceRatio, + parentShellIndex = bestShell, + shellCentroidDistance = Mathf.Sqrt(bestShellDistSq), + shellAngleDeg = bestShellAngle == float.MaxValue ? 0f : bestShellAngle, + shellAreaRatio = shellRatio, + shellPerpDistanceNorm = perpNorm, + fineArea = fine[f].area, + }); + } + } + + // Default write — used when the probe is invoked standalone (no + // unified benchmark output directory). Writes to BenchmarkReports/. + static string WriteReport(string lgName, List records) + => WriteReport(lgName, records, null); + + /// Write the probe report. If is + /// non-null the CSV lands under it as probe.csv (fixed + /// name — one probe per case, subfolder identifies the technique). + /// Otherwise falls back to the legacy + /// BenchmarkReports/hierdiag_<stamp>_<name>.csv layout for + /// standalone runs. + static string WriteReport(string lgName, List records, + string outputDir) + { + string dir; + string path; + if (!string.IsNullOrEmpty(outputDir)) + { + dir = outputDir; + Directory.CreateDirectory(dir); + path = Path.Combine(dir, "probe.csv"); + } + else + { + string projectRoot = Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + dir = Path.Combine(projectRoot, "BenchmarkReports"); + Directory.CreateDirectory(dir); + string stamp = System.DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff", + CultureInfo.InvariantCulture); + path = Path.Combine(dir, $"hierdiag_{stamp}_{Sanitize(lgName)}.csv"); + } + + var sb = new StringBuilder(); + sb.AppendLine("groupKey,lodIndex,faceIndex," + + "parentFaceIndex,faceCentroidDistance,faceAngleDeg,faceAreaRatio," + + "parentShellIndex,shellCentroidDistance,shellAngleDeg,shellAreaRatio," + + "shellPerpDistanceNorm,fineArea"); + var inv = CultureInfo.InvariantCulture; + foreach (var r in records) + { + sb.Append(CsvField(r.groupKey)).Append(','); + sb.Append(r.lodIndex.ToString(inv)).Append(','); + sb.Append(r.faceIndex.ToString(inv)).Append(','); + sb.Append(r.parentFaceIndex.ToString(inv)).Append(','); + sb.Append(r.faceCentroidDistance.ToString("R", inv)).Append(','); + sb.Append(r.faceAngleDeg.ToString("R", inv)).Append(','); + sb.Append(r.faceAreaRatio.ToString("R", inv)).Append(','); + sb.Append(r.parentShellIndex.ToString(inv)).Append(','); + sb.Append(r.shellCentroidDistance.ToString("R", inv)).Append(','); + sb.Append(r.shellAngleDeg.ToString("R", inv)).Append(','); + sb.Append(r.shellAreaRatio.ToString("R", inv)).Append(','); + sb.Append(r.shellPerpDistanceNorm.ToString("R", inv)).Append(','); + sb.Append(r.fineArea.ToString("R", inv)); + sb.AppendLine(); + } + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + return path; + } + + static void LogSummary(string lgName, List records, + int groupsProbed, int groupsSkipped, int deepestIdx) + { + if (records.Count == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierDiag] {lgName}: 0 face records " + + $"(groups probed={groupsProbed}, skipped={groupsSkipped})."); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($"[HierDiag] '{lgName}' — {records.Count} fine faces " + + $"probed against deepest LOD (idx={deepestIdx}). " + + $"groups: {groupsProbed} probed, {groupsSkipped} skipped."); + + // Mode A: face-level (naive v1 baseline) + sb.AppendLine(" FACE-LEVEL (v1: nearest deepest triangle):"); + EmitCoverage(sb, records, r => r.faceAngleDeg); + EmitRatioStats(sb, records, r => r.faceAreaRatio, " "); + + // Mode B: shell-level (v2 main signal — matches what HierarchicalRepack would do) + sb.AppendLine(" SHELL-LEVEL (v2: K=10 nearest shells, best angle):"); + EmitCoverage(sb, records, r => r.shellAngleDeg); + EmitRatioStats(sb, records, r => r.shellAreaRatio, " "); + + // Mode C: shell-level perpendicular offset (v3 — distinguishes + // "fine face is glued to parent shell" from "floating in space + // above the parent shell"). Threshold buckets correspond to the + // promotion-criterion proposal in the conversation: <1% = tight, + // <5% = within typical thickness, <15% = clearly attached, >=15% + // = promotion candidate (floating detail without geometric parent). + sb.AppendLine(" SHELL-PERP (v3: |dot(fine.c - shell.c, shell.n)| / meshDiagonal):"); + EmitPerpStats(sb, records, " "); + + // Combined promotion verdict using the proposed default thresholds: + // θ > 60° OR ratio > 1.5 OR perpNorm > 0.05 + // → promote (face gets its own atlas slot rather than projecting + // into a parent shell). + int promote = 0; + foreach (var r in records) + if (r.shellAngleDeg > 60f || r.shellAreaRatio > 1.5f || r.shellPerpDistanceNorm > 0.05f) + promote++; + float promPct = 100f * promote / records.Count; + sb.AppendLine($" PROMOTION (default thresholds θ>60° | ratio>1.5 | perp>5%): " + + $"{promote}/{records.Count} ({promPct:F1}%) faces would be promoted to their own atlas slot"); + + // Per-LOD shell-level breakdown + var byLod = records.GroupBy(r => r.lodIndex).OrderBy(g => g.Key); + foreach (var g in byLod) + { + int n = g.Count(); + float mAng = g.Average(r => r.shellAngleDeg); + int s30 = g.Count(r => r.shellAngleDeg < 30f); + int s60 = g.Count(r => r.shellAngleDeg < 60f); + float mPerp = g.Average(r => r.shellPerpDistanceNorm); + int p1 = g.Count(r => r.shellPerpDistanceNorm < 0.01f); + int p5 = g.Count(r => r.shellPerpDistanceNorm < 0.05f); + sb.AppendLine($" LOD{g.Key}: {n,6} faces shell-θ mean={mAng,5:F1}° " + + $"θ<30°: {100f * s30 / n:F1}% θ<60°: {100f * s60 / n:F1}% " + + $"perp mean={mPerp,6:F3} <1%: {100f * p1 / n:F1}% <5%: {100f * p5 / n:F1}%"); + } + + UvtLog.Info(UvtLog.Category.Benchmark, sb.ToString()); + } + + static void EmitPerpStats(StringBuilder sb, List records, string indent) + { + var vals = new List(records.Count); + foreach (var r in records) + { + float v = r.shellPerpDistanceNorm; + if (!float.IsInfinity(v) && !float.IsNaN(v)) vals.Add(v); + } + if (vals.Count == 0) return; + vals.Sort(); + float Pct(double p) + { + int i = (int)System.Math.Round(p * (vals.Count - 1)); + if (i < 0) i = 0; + if (i >= vals.Count) i = vals.Count - 1; + return vals[i]; + } + int total = vals.Count; + int under1pct = 0; foreach (var x in vals) if (x < 0.01f) under1pct++; + int under5pct = 0; foreach (var x in vals) if (x < 0.05f) under5pct++; + int under15pct = 0; foreach (var x in vals) if (x < 0.15f) under15pct++; + sb.AppendLine($"{indent}distribution: p50={Pct(0.50):F4} p90={Pct(0.90):F4} " + + $"p99={Pct(0.99):F4} max={vals[vals.Count - 1]:F4}"); + sb.AppendLine($"{indent} <1% mesh diag (glued): {under1pct,7} / {total} ({100f * under1pct / total:F1}%)"); + sb.AppendLine($"{indent} <5% mesh diag (attached):{under5pct,7} / {total} ({100f * under5pct / total:F1}%)"); + sb.AppendLine($"{indent} <15% mesh diag (close): {under15pct,7} / {total} ({100f * under15pct / total:F1}%)"); + sb.AppendLine($"{indent} >=15% (floating): {total - under15pct,7} / {total} ({100f * (total - under15pct) / total:F1}%)"); + } + + static void EmitCoverage(StringBuilder sb, List records, + System.Func getAngle) + { + int total = records.Count; + foreach (var theta in kThetaSamples) + { + int within = 0; + foreach (var r in records) if (getAngle(r) < theta) within++; + float pct = 100f * within / total; + sb.AppendLine($" θ <{theta,5:F1}°: {within,7} / {total} " + + $"({pct,5:F1}%) retained-area ≥ {Mathf.Cos(theta * Mathf.Deg2Rad) * 100f:F0}%"); + } + } + + static void EmitRatioStats(StringBuilder sb, List records, + System.Func getRatio, string indent) + { + var ratios = new List(records.Count); + foreach (var r in records) + { + float v = getRatio(r); + if (!float.IsInfinity(v) && !float.IsNaN(v)) ratios.Add(v); + } + if (ratios.Count == 0) return; + ratios.Sort(); + float Pct(double p) + { + int i = (int)System.Math.Round(p * (ratios.Count - 1)); + if (i < 0) i = 0; + if (i >= ratios.Count) i = ratios.Count - 1; + return ratios[i]; + } + int over = 0; foreach (var x in ratios) if (x > 1.0f) over++; + sb.AppendLine($"{indent}area ratio (fine/parent): " + + $"p50={Pct(0.50):F3} p90={Pct(0.90):F3} p99={Pct(0.99):F3} " + + $"max={ratios[ratios.Count - 1]:F3}"); + sb.AppendLine($"{indent} fine LARGER than parent (ratio>1): " + + $"{over}/{ratios.Count} ({100f * over / ratios.Count:F1}%) → promotion candidates"); + } + + static string CsvField(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + if (s.IndexOfAny(new[] { ',', '"', '\n', '\r' }) < 0) return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; + } + + static string Sanitize(string s) + { + if (string.IsNullOrEmpty(s)) return "unnamed"; + var sb = new StringBuilder(s.Length); + foreach (char c in s) + sb.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '_'); + return sb.ToString(); + } + } +} diff --git a/Editor/HierarchicalDiag.cs.meta b/Editor/HierarchicalDiag.cs.meta new file mode 100644 index 00000000..831ea465 --- /dev/null +++ b/Editor/HierarchicalDiag.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 16d665003e41461ba13b763d47d6f6ef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/HierarchicalRepack.cs b/Editor/HierarchicalRepack.cs new file mode 100644 index 00000000..731385fd --- /dev/null +++ b/Editor/HierarchicalRepack.cs @@ -0,0 +1,3750 @@ +// HierarchicalRepack.cs — builder for the hierarchical UV2 atlas +// (cascade plan v2 — group-then-final-pack). +// +// CURRENT STATE: legacy PR-2 single-proxy / promote-overlay classifier +// and the PR-3 single-proxy projector have both been removed. The +// surviving Build() runs only Stage 1 (proxy unwrap variants) + +// Stage B (per-LOD classical unwrap diagnostic) + Stage 2 (Poisson +// samples on the deepest LOD) + Stage 3 (per-fine-LOD sample projection +// bookkeeping). Stage C (per-LOD 3D shell extract + group seed), +// Stage D (cascade pairwise grouping deep→fine), Stage E (final pack +// of all canonical charts + per-LOD uv2), Stage F (apply + bake) +// come next per Documentation~/HIERARCHICAL_CASCADE_PLAN.md. +// +// PUBLIC API still exposed: +// • Build(LODGroup, Options) → Result +// • BuildAndWriteForCase(LODGroup, Options, string) → Result +// • BuildFinalMeshes(LODGroup, Result) (consumes finalUv2/Tris/ +// SourceVertexIdx — currently null until Stage E re-populates) +// +// Public types kept for cross-stage data flow; legacy fields +// (LightingDomain, PromotedCluster, faceToDomain, baseShellCount, +// atlasPixelWidth/Height, totalFineFaces/promotedFineFaces/...) are +// gone. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; + +namespace SashaRX.UnityMeshLab +{ + internal static class HierarchicalRepack + { + // ─── Public configuration ──────────────────────────────────── + + public struct Options + { + /// Adjacent-face normal threshold for shell extraction (degrees). + /// Two adjacent faces belong to the same shell if their normals differ by + /// less than this. Matches xatlas hard-edge convention. + public float shellNormalThresholdDeg; + + /// Post-extraction merge pass: two adjacent SHELLS (sharing + /// at least one canonical edge) get merged if their area-weighted + /// dominant normals differ by less than this many degrees. Compensates + /// for the per-face threshold being too strict at shell boundaries — + /// a single noisy triangle on a planked surface can otherwise split + /// what's physically one wall into two base shells, which then + /// breaks face-consensus for any fine-LOD face that straddles the + /// split. 0 = disabled (keep raw union-find output). + public float shellMergeAngleDeg; + + /// Target atlas resolution (pixels). Final atlas may be slightly + /// larger if shells don't fit; naive packer in PR-2 grows the height. + public int atlasResolutionPx; + + /// Padding between domains in atlas pixels. Intra-domain overlap + /// is intentional (shared lighting domain feature — see EXPERIMENTS.md); + /// padding only applies BETWEEN domains, not within. + public int interDomainPaddingPx; + + /// Per-vertex closest-surface-distance threshold (as a fraction + /// of the deepest-LOD world-space mesh diagonal). A fine-LOD vertex + /// whose distance to the deepest-LOD surface is ≤ this × meshDiag is + /// considered "on the proxy" and inherits the proxy's UV via overlay. + /// Replaces the PR-2.5 trio (overlayAngleDeg/PerpNorm/ExtentSlack) — + /// per-vertex projection captures angle + offset + extent fit in one + /// scalar, the way Frostbite's lightmap-proxy pipeline does. + public float overlayDistNorm; + + /// Stage D tiny-shell merge threshold (sweep 2026-06-03). + /// A finer-LOD shell whose totalArea ≤ skipAreaFrac × totalFineArea + /// AND faceCount ≤ skipMaxFaceCount is force-joined to its + /// modal proxy parent even when the regular matchedFrac/minHits + /// gate fails, because it's almost certainly thin trim / wire / + /// fastener noise — the dominant source of fresh-group explosion + /// on detail-heavy assets (Carousel 269→922 without this; Stage E + /// would waste atlas space on every single piece of wire). If the + /// tiny shell has no proxy match at all (bestProxy < 0) it + /// still opens a fresh group; counted as tinyOrphan. + public float skipAreaFrac; + + /// Companion to — gate is AND, + /// so a shell needs to be both area-small AND face-count-small to + /// qualify as "tiny". Stops the rule from merging long thin + /// strips (small area, many faces) that genuinely deserve their + /// own domain. + public int skipMaxFaceCount; + + /// Which proxy UV2 variant drives the downstream + /// per-shell projection. All three variants are still emitted + /// as diagnostic PNGs (proxy_uv2_clean/raw/auto.png) regardless + /// — this only picks which one Stage 2+ actually consumes. + public ProxyMode proxyMode; + + /// Stage 2 Poisson sampling rate: samples per unit + /// proxy area, scaled so the total sample count is + /// invariant to mesh scale. Effective per-tri count is + /// max(3, ceil(proxySampleDensity × tri_area / + /// meshDiag²)). Default 4000 → ~4000 samples on a 1m² mesh, + /// proportional otherwise. Higher = denser pattern at + /// the cost of Stage 3 projection runtime. + public int proxySampleDensity; + + /// Stage D cascade-match threshold: a finer-LOD shell + /// joins its deeper-LOD parent's lighting-domain group when the + /// MODAL deeper shell's hit fraction (bestProxyHits / totalShellHits) + /// is ≥ this. Below → the shell opens a fresh group (it's detail + /// the deeper LOD lacks). Lower = more aggressive merging (risks + /// lighting bleed across domains); higher = more fresh groups + /// (risks atlas waste / over-segmentation). Sweep this with + /// to find the knee per asset class. + public float cascadeMatchFrac; + + /// Stage D cascade-match gate: a finer-LOD shell needs at + /// least this many total projected sample hits before its modal + /// vote is trusted to join a parent group. Below → fresh group + /// regardless of fraction (too few samples = noisy vote, usually + /// a sliver shell). Pairs with . + public int cascadeMinHits; + + public static Options Default => new Options + { + shellNormalThresholdDeg = 30f, + shellMergeAngleDeg = 15f, + atlasResolutionPx = 1024, + interDomainPaddingPx = 4, + overlayDistNorm = 0.03f, + skipAreaFrac = 0.001f, + skipMaxFaceCount = 4, + proxyMode = ProxyMode.Clean, + proxySampleDensity = 4000, + cascadeMatchFrac = 0.5f, + cascadeMinHits = 4, + }; + } + + /// Which UV2 layout drives Stage 2+ per-shell projection. + public enum ProxyMode + { + /// UV0 → sym-split → ARAP → xatlas pack. Preserves + /// artist UV chart partition (right answer for curved surfaces + /// where each strip preserves uniform texel density along the + /// arc); fixes mirrored overlaps and stretched islands. + /// Production default. + Clean = 0, + /// UV0 → xatlas pack only. No sym-split, no ARAP. + /// Diagnostic — shows what the cleanup steps contribute. + Raw = 1, + /// True auto-unwrap from positions + normals, no UV0 + /// input. xatlas builds charts from hard-edge detection — tends + /// to pie-slice curved surfaces into single charts, which gives + /// uneven texel density across the arc. Useful for assets with + /// missing or unusable UV0; not recommended for production on + /// curved geometry. + Auto = 2, + } + + public class Result + { + /// PR-3 stage 1: clean UV2 layout for the deepest LOD, + /// produced by xatlas auto-pack on its existing UV0. Diagnostic / + /// reference for fine-LOD shell projection in subsequent stages. + /// Null if the deepest LOD has no UV0 or xatlas couldn't pack. + public Vector2[] proxyUv2; + /// Companion to — the deepest LOD's + /// triangle indices (mesh.triangles) so the UV2 array can be + /// rendered as a UV layout PNG. + public int[] proxyTris; + /// World-space positions of 's + /// vertex set — indexed by . Needed by + /// Stage 2 sampling because sym-split (clean) or chart-seam + /// splitting (auto) can rewrite the proxy's vertex layout vs + /// the original deepest-LOD mesh. + public Vector3[] proxyWorldVerts; + /// Stage 1 diagnostic: clean variant (sym-split + ARAP + /// + xatlas pack), always populated regardless of which mode + /// picks for downstream use. + public Vector2[] proxyUv2Clean; + public int[] proxyTrisClean; + public Vector3[] proxyWorldVertsClean; + /// Stage 1 comparison: raw repack of the deepest LOD's + /// UV0 through xatlas (no sym-split, no ARAP). Shows what those + /// steps contribute against the "clean" pipeline. + public Vector2[] proxyUv2Raw; + public int[] proxyTrisRaw; + public Vector3[] proxyWorldVertsRaw; + /// Stage 1 comparison: TRUE auto-unwrap — xatlas builds + /// charts from scratch using the deepest LOD's positions + + /// normals (no UV0 hint). Requires the native bridge's + /// xatlasAddMesh export. + public Vector2[] proxyUv2Auto; + public int[] proxyTrisAuto; + public Vector3[] proxyWorldVertsAuto; + /// PR-3 Stage 2: Poisson-style samples drawn uniformly + /// across the active proxy's 3D surface, each carrying the + /// proxy UV2 it lands on. Drives Stage 3+ projection onto fine + /// LOD shells. Null until Stage 2 runs. + public ProxySample[] proxySamples; + + /// PR-3 Stage 3: per-fine-LOD projection of proxy + /// samples onto the LOD's 3D surface. Indexed by LOD; entries + /// for the deepest LOD or for LODs that don't have a renderer + /// are null. Lets Stage 4 ask "which proxy shell dominates + /// this fine face" and "how many samples hit this region" in + /// O(1) per query. + public FineLodProjection[] lodProjections; + /// PR-3 Stage 5 output: final UV2 per vertex, per fine + /// LOD. Indexed by LOD; entries for the deepest LOD or for + /// LODs that didn't get a Stage 5 pass are null. Inner array + /// length is the OUTPUT vertex count after seam-vertex + /// duplication (vertices shared between fine faces that + /// landed on different proxy faces get one copy per proxy + /// face — keeps adjacent fine faces from dragging UVs + /// across the atlas). Pair with and + /// . + public Vector2[][] finalUv2; + /// PR-3 Stage 5: rewritten triangle index buffer per + /// fine LOD, indexing into the per-LOD finalUv2 / output + /// vertex set (NOT the original mesh.triangles). + public int[][] finalTris; + /// PR-3 Stage 5: for each output vertex, the index + /// into the original mesh.vertices it was duplicated from. + /// Stage 6 (mesh rebuild) will use this to copy positions, + /// normals, tangents, UV0, etc. onto the duplicated mesh. + /// + public int[][] finalSourceVertexIdx; + /// PR-3 Stage 5: total V extent of the final atlas + /// after promotion strip is appended. 1.0 if every fine shell + /// overlaid cleanly; higher when shells needed promotion. + /// + public float finalAtlasV; + /// PR-3 Stage 6: cloned fine-LOD meshes with the + /// new uv2 baked in (and vertex attributes duplicated to + /// match the seam-vertex split). Indexed by LOD; null for + /// the deepest LOD and for LODs whose projection failed. + /// Built on demand by ; the + /// menu-driven Apply step swaps these into the renderers' + /// MeshFilters via Undo. + public Mesh[] finalMeshes; + /// PR-3 Stage B: classical xatlas unwrap (sym-split + /// + ARAP + pack) of each FINE LOD independently. Indexed + /// by LOD; null for deepest and for LODs without UV0. + /// Diagnostic-only at this stage -- Stage D's cascade + /// projection will read these to seed unmatched-shell + /// repacking. PNG output: lod{N}_classical_uv2.png. + /// + public Vector2[][] fineClassicalUv2; + /// PR-3 Stage B: triangle index buffer for the + /// classical unwrap above. Indexed by LOD. xatlas may split + /// vertices at chart seams so this differs from the source + /// mesh.triangles. Pair with fineClassicalUv2. + public int[][] fineClassicalTris; + + // ─── Stage C: per-LOD 3D shells + group seed ──────────── + /// Stage C: 3D shells extracted per LOD via + /// ExtractShells on each LOD's geometry. Indexed by LOD + /// level (matches LODGroup.GetLODs()). Each LOD has its + /// own shell set — same params (shellNormalThresholdDeg) + /// across LODs so shells on geometrically-equivalent LODs + /// come out comparable. + public Shell3D[][] perLodShells; + /// Stage C: face → shellId per LOD. -1 for + /// degenerate faces filtered out by ExtractShells. Pair + /// with . + public int[][] perLodFaceToShell; + /// Stage C / Stage D: lighting domain groups. + /// Stage C seeds one group per deepest-LOD shell. Stage D + /// joins finer-LOD shells into existing groups (cascade + /// matching) or creates new groups for shells with no + /// match. Stage E packs canonical shells into the final + /// atlas. + public LightingDomainGroup[] groups; + /// Stage C / Stage D: per-LOD shell → group id. + /// Stage C fills the deepest LOD with 0..N-1 (one group + /// per shell). Stage D fills the rest via cascade voting; + /// -1 means the shell has no group yet (unmatched, Stage + /// D will assign a fresh group). + public int[][] perLodShellToGroup; + /// Stage D bookkeeping: one entry per LOD transition the + /// cascade walked (deepest→0), in walk order. Drives the + /// stage_d_sweep.csv diagnostic so threshold sweeps can be scored + /// by join/new ratios without re-deriving from perLodShellToGroup. + /// Null until Stage D runs. + public List cascadeStats; + + // ─── Stage E: packed lighting-domain atlas ────────────── + /// Stage E (slice E1): packed UV of the canonical + /// charts — one chart per lighting-domain group, each group's + /// canonical (deepest-member) shell projected onto its own + /// plane and laid out by xatlas. Normalised [0,1]. Diagnostic + /// render: domains_atlas.png. Null until Stage E runs. + public Vector2[] domainAtlasUv; + /// Stage E (slice E1): index buffer for + /// . xatlas may split vertices at + /// chart seams, so this is its own buffer. + public int[] domainAtlasTris; + /// Stage E (slice E1): packed atlas rect (normalised + /// [0,1]) of each lighting-domain group, indexed by groupId. + /// This is the SHARED layout: slice E2 maps every LOD's member + /// shells into their group's rect so the whole lighting domain + /// occupies the same atlas region across all LODs. Null entries + /// (zero-size rect) mean the group contributed no packable + /// geometry. + public Rect[] domainAtlasRects; + /// Stage E: per-group affine that maps a point's + /// canonical-plane coordinate (in WORLD units: dot(worldPos − + /// canonCentroid, canonBasisU/V)) to packed atlas UV — + /// uv = (su·inU + ou, sv·inV + ov). Fitted from the + /// canonical chart's placed UV, so the canonical reproduces its + /// own placement and every finer member of the group reuses the + /// SAME affine → all members align in the same atlas region at the + /// SAME texel density (xatlas packed at a fixed texelsPerUnit, so + /// su≈sv is uniform across groups). Replaces the old per-shell + /// [0,1] normalisation, which distorted non-square shells and gave + /// each shell a different texel density. Indexed by groupId. + public DomainPlacement[] domainPlacements; + + // ─── Stage E2/E3: per-face provenance + atlas metrics ──── + /// Stage E2: per LOD, per EMITTED face → Stage C shell + /// id (parallel to finalTris/3). Lets Stage E3 tell legitimate + /// same-shell seam ties from genuine UV overlaps. + public int[][] finalFaceShell; + /// Stage E2: per LOD, per emitted face → lighting-domain + /// group id (-1 when the shell had no group). + public int[][] finalFaceGroup; + /// Stage E2: per LOD, faces emitted with the uv2 (0,0) + /// fallback because their group had no valid placement. Non-zero + /// means Stage E1 has gaps worth investigating. + public int[] finalUnplacedFaces; + /// Stage E3: objective per-LOD atlas quality metrics — + /// the scalars threshold sweeps optimise and Apply reports. + /// Indexed by LOD; default entries where the LOD produced no + /// final UV2. CSV: stage_e_metrics.csv. + public StageEMetrics[] stageEMetrics; + /// Stage E3: per-LOD coverage/overlap raster + /// (stageEMetricsRes² Color32) — grey covered, red overlapping, + /// near-black empty. PNG: lod{N}_overlap.png. + public Color32[][] stageEOverlapPx; + /// Stage E3: side length of the metric raster. + public int stageEMetricsRes; + + public string error; + } + + /// Stage D per-transition counters for one (proxy, fine) + /// LOD pair. Logged live and persisted on + /// for the threshold sweep CSV. + public struct CascadeStat + { + public int liProxy; + public int liFine; + public int samples; + public int hits; + public int missed; + /// Finer shells that inherited a parent group. + public int joined; + /// Finer shells that opened a fresh group (no match). + public int fresh; + /// Finer shells already assigned by an earlier + /// (deeper) transition — skipped this pass. + public int reused; + /// Tiny shells (area <= skipAreaFrac × totalFine AND + /// faceCount <= skipMaxFaceCount) that failed the regular + /// matchedFrac/minHits test but had a resolvable parent, so + /// they were force-joined instead of opening a fresh group. + /// Sweep 2026-06-03 showed these are the dominant source of + /// fresh-group explosion on detail-heavy assets (thin trim, + /// wire, fasteners). + public int tinyJoined; + /// Tiny shells with no proxy match at all (deeper LOD + /// genuinely lacks the geometry) — they still open fresh + /// groups; subset of . + public int tinyOrphan; + } + + /// Stage E: affine map from a canonical-plane coordinate (world + /// units) to packed atlas UV, per lighting-domain group. See + /// . + public struct DomainPlacement + { + public Vector2 uvc; // canonical UV0-island centroid + public float scale; // uniform scale: in = (uv0 − uvc)·scale + public float su, ou; // atlasU = su·in.x + ou + public float sv, ov; // atlasV = sv·in.y + ov + public bool valid; + } + + /// Stage E3: per-LOD atlas quality metrics measured on the + /// final cascaded UV2 (texel-centre raster at the atlas resolution). + /// See ; persisted to + /// stage_e_metrics.csv by the benchmark. + public struct StageEMetrics + { + public int lod; + /// Faces in the source LOD mesh — compare with + /// to spot silently dropped geometry. + public int srcFaces; + /// Faces emitted into finalUv2/finalTris. + public int faces; + /// Faces emitted with the uv2 (0,0) placement fallback. + public int unplacedFaces; + /// ~Zero UV-area faces — not rasterised, no density sample. + public int degenUvFaces; + /// Faces with negative (flipped) UV winding. + public int invertedFaces; + /// Output verts outside [0,1] by more than half a texel. + public int oobVerts; + public int coveredTexels; + /// coveredTexels / res² × 100. + public float utilizationPct; + /// Texels claimed by 2+ triangles that are neither the + /// same face nor same-shell seam-adjacent — the direct + /// lightmap-bleed proxy (UV0 mirror-reuse, residual folds). + public int overlapTexels; + public float overlapPctOfCovered; + /// Distinct cross-shell pairs in conflict (capped 4096). + public int overlapShellPairs; + /// Area-derived texels-per-world-unit (√(Σtexel²/Σarea3D)). + public float tpuMean; + /// Area-weighted 1st / 99th percentile texel density. + public float tpuP1, tpuP99; + /// tpuP99 / tpuP1 — 1.0 = perfectly uniform. + public float tpuSpread; + /// Texels owned by groups whose canonical lives on a + /// DIFFERENT LOD — the population the cascade promises to align. + public int xLodTexels; + /// % of that fall inside the + /// canonical LOD's footprint for the same group (3×3 dilated). + /// Low = the asset violates the shared-UV0-layout assumption → + /// one bake would NOT be valid across LODs. + public float xLodContainedPct; + /// Groups with ≥16 texels at this LOD and <50% + /// containment — concrete cross-LOD alignment failures. + public int misalignedGroups; + } + + /// Stage C / D / E: a single lighting domain — a set + /// of shells across multiple LODs that share lighting. Owned + /// by its canonicalLod shell (the deepest-LOD member), + /// whose geometry + UV parameterisation are the chart that + /// Stage E packs into the final atlas. Finer members are + /// projected onto the canonical member's plane and inherit + /// its placed UV by barycentric pull. + public struct LightingDomainGroup + { + public int groupId; + /// Deepest LOD level that has a shell in this + /// group. The canonical chart for Stage E packing. + public int canonicalLod; + /// Shell id (index into perLodShells[canonicalLod]) + /// of the canonical member. + public int canonicalShellId; + /// All (lod, shellId) members of the group. + /// Includes the canonical member as the first entry. + /// Stage D appends finer members during cascade matching. + /// + public List<(int lod, int shellId)> members; + } + + /// PR-3 Stage 3 output: for one fine LOD, how the proxy + /// samples landed on its surface. Per-face aggregates feed Stage 4 + /// (per-shell affine fit, residual cluster) and the diagnostic + /// heat-map (lod{N}_proxy_hits.png). + public struct FineLodProjection + { + /// For each face: number of proxy samples whose + /// closest-point-on-fine-mesh landed within the overlay + /// distance threshold on this face. + public int[] perFaceHitCount; + /// For each face: proxy shell that contributed the + /// most hits, or -1 if no hits. + public int[] perFaceDominantProxyShell; + /// For each face: mean closest-point distance across + /// the samples that hit it (world units). 0 if no hits. + public float[] perFaceAvgDist; + /// Total samples that hit any face on this LOD. + public int totalHits; + /// Samples whose closest-point distance exceeded the + /// overlay threshold for this LOD — i.e. proxy regions with no + /// counterpart on this fine LOD (rare; usually means proxy has + /// extra geometry the fine LOD doesn't, or vice versa). + public int missedSamples; + /// PR-3 Stage 3 bookkeeping: for each face, indices + /// into of every sample that + /// landed on it. Variable-length arrays (null if no hits). + /// Currently only used to wire perFaceDominantProxyShell; + /// retained because the diagnostic PNGs read it directly. + public int[][] perFaceHitSampleIdx; + } + + /// A Poisson-distributed point on the proxy surface paired + /// with the metadata Stage 3+ needs to make per-shell decisions. + /// Storing direction + chart + ID upfront avoids re-derivation in + /// later stages and lets the projection trace carry richer signal + /// (e.g. orientation-filtered shell vote, per-chart residual fit). + public struct ProxySample + { + public Vector3 worldPos; + /// Outward face normal of the proxy triangle this sample + /// sits on, in world space. Stage 4 can reject candidate fine + /// shells whose normal disagrees by > threshold. + public Vector3 worldNormal; + public Vector2 uv2; + /// Index of the proxy triangle this sample sits inside — + /// lets Stage 3 group samples by proxy face / chart when needed. + public int proxyFaceIdx; + /// Index of the proxy UV chart (shell) this sample's + /// face belongs to. Two faces share a chart iff their UV2 is + /// continuous across the shared edge — extracted via + /// UvShellExtractor on the active proxy (proxyUv2 + proxyTris). + public int proxyShellId; + /// Sequential ID assigned at sampling time. Stable + /// because the LCG seed is fixed; lets later stages refer to + /// specific samples (e.g. for debug picking). + public int sampleId; + } + + // ─── Public entry ──────────────────────────────────────────── + + /// + /// Build the hierarchical atlas layout for a LODGroup. Returns a + /// with the per-face domain assignment and the + /// packed atlas dimensions. The actual UV2 write happens later in + /// InverseTransfer (PR-3) — this stage decides "which domain does + /// this face belong to" and "where is that domain in the atlas". + /// + /// Single-renderer-per-LOD only in PR-2. Multi-renderer support + /// deferred to PR-3 or later. + /// + public static Result Build(LODGroup lg, Options opts) + { + var result = new Result(); + if (lg == null) { result.error = "LODGroup is null"; return result; } + var lods = lg.GetLODs(); + if (lods == null || lods.Length < 2) + { + result.error = "LODGroup needs at least 2 LOD levels"; + return result; + } + + // Pick a single renderer per LOD (first valid). Multi-renderer + // LODs cause a warning but the pipeline still runs with the + // first one — full multi-mesh support is a separate PR. + int lodCount = lods.Length; + var meshes = new Mesh[lodCount]; + var xforms = new Transform[lodCount]; + for (int li = 0; li < lodCount; li++) + { + var rs = lods[li].renderers; + if (rs == null) continue; + int found = 0; + foreach (var r in rs) + { + if (r == null) continue; + var mf = r.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + if (found == 0) { meshes[li] = mf.sharedMesh; xforms[li] = r.transform; } + found++; + } + if (found > 1) + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] LOD{li}: {found} renderers — using first only (multi-mesh deferred)."); + } + + int deepest = lodCount - 1; + while (deepest > 0 && meshes[deepest] == null) deepest--; + if (deepest <= 0 || meshes[deepest] == null) + { + result.error = "No usable deepest-LOD mesh"; + return result; + } + if (meshes[0] == null) + { + result.error = "No usable LOD0 mesh"; + return result; + } + + // Mesh diagonal — reference scale for sampling density, + // distance thresholds and canonical-vertex deduplication. + // Legacy PR-2 deepest-LOD shell extraction / promote-overlay + // classifier was removed; cascade architecture (plan v2) + // does per-LOD shell work inside its own stages. + float meshDiag = ComputeMeshDiagonal(meshes[deepest], xforms[deepest]); + if (meshDiag < 1e-6f) meshDiag = 1f; + + // PR-3 Stage 1 (proxy UV2 generation): produce up to three + // candidate UV2 layouts for the deepest LOD so the operator can + // compare visually: + // • clean — UV0 → sym-split → xatlas pack (ARAP on by default) + // • raw — UV0 → xatlas pack only (no sym-split, no ARAP). + // Diagnostic: shows what those steps contribute. + // • auto — TRUE auto-unwrap: positions + normals → xatlas + // builds charts from scratch (no UV0 input). Needs + // the native bridge's xatlasAddMesh export. + // All variants run on clones of the deepest mesh so the + // operator's scene assets are untouched. The "clean" variant + // is the one that will drive subsequent stages; the others are + // diagnostic-only. + try { ComputeProxyUv2Variants(meshes[deepest], xforms[deepest], opts, lg.name, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] proxy UV2 stage 1 failed on '{lg.name}': {ex.Message}"); + } + // PR-3 Stage B: classical xatlas unwrap of each non-deepest + // LOD independently. Diagnostic only at this stage; Stage D + // will use these to seed the cascade repack. + try { ComputeFineClassicalUnwraps(lg, opts, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] fine classical unwrap stage B failed on '{lg.name}': {ex.Message}"); + } + // PR-3 Stage 2: Poisson samples on the active proxy. + try { GenerateProxySamples(opts, meshDiag, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] proxy sampling stage 2 failed on '{lg.name}': {ex.Message}"); + } + // PR-3 Stage 3: project samples onto each fine LOD. + try { ProjectProxySamplesOntoFineLods(lg, opts, meshDiag, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] proxy projection stage 3 failed on '{lg.name}': {ex.Message}"); + } + // Stage C: per-LOD 3D shell extract + seed deepest-LOD + // groups. No matching / no UV here — only segmentation + + // data-model seed. Stage D consumes perLodShells + + // perLodShellToGroup + groups. + try { ExtractPerLodShellsAndSeedGroups(lg, opts, meshDiag, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] stage C shells/groups failed on '{lg.name}': {ex.Message}"); + } + // Stage D: cascade grouping deep→fine. Walks each (li_proxy, + // li_proxy-1) transition, samples the deeper LOD's surface + // tagged with the deeper LOD's shell id, projects samples onto + // the finer LOD via closest-face, and votes per finer shell + // whether to join the parent's group (matchedFrac >= 0.5) or + // open a new group. Membership only — UV is Stage E's job. + try { CascadeGroupShells(lg, opts, meshDiag, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] stage D cascade grouping failed on '{lg.name}': {ex.Message}"); + } + // Stage E (slice E1): parameterise each lighting-domain group's + // canonical shell planarly and pack the charts into one shared + // atlas via xatlas (faceMaterial = groupId forces a chart + // boundary per group). Produces the per-group atlas rects that + // slice E2 will map every LOD's member shells into. Layout only + // — no per-LOD UV2 / mesh writing here. + try { PackDomainCharts(lg, opts, meshDiag, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] stage E domain pack failed on '{lg.name}': {ex.Message}"); + } + // Stage E (slice E2): the cascade — every LOD's shells project into + // their group's atlas rect so matched shells share the region + // across LODs (one bake valid everywhere) and unmatched shells use + // their repacked slot. Produces per-LOD finalUv2/finalTris/ + // finalSourceVertexIdx. + try { BuildCascadedUv2(lg, opts, meshDiag, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] stage E cascade uv2 failed on '{lg.name}': {ex.Message}"); + } + // Stage E (slice E3): objective atlas quality metrics — overlap + // texels/pairs, inverted/degenerate faces, texel-density spread, + // cross-LOD containment. The scalar that threshold sweeps + // optimise and the reliability gate the Apply menu reports. + try { ComputeStageEMetrics(lg, opts, result); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] stage E3 metrics failed on '{lg.name}': {ex.Message}"); + } + return result; + } + + // ─── Internal types ────────────────────────────────────────── + + struct Face3D + { + public Vector3 centroid; + public Vector3 normal; // unit + public float area; + } + + public struct Shell3D + { + public Vector3 centroid; // area-weighted + public Vector3 dominantNormal; // area-weighted, unit + public float totalArea; + public int faceCount; + public List faceIndices; // into the source mesh's tris[] (faces, not verts) + public Vector3 basisU; + public Vector3 basisV; + public float extentU; // half-extent along basisU (centred at centroid) + public float extentV; + } + + // ─── Face data + diagonal ──────────────────────────────────── + + /// Build per-face data for one mesh. Out-params: + /// = world-space copy of mesh.vertices + /// (needed for vertex-based extent computation and per-vertex + /// projection in the new classifier). = + /// mesh.triangles as-is (indexes worldVerts). + /// rewrites mesh.triangles using position-deduplicated vertex indices + /// (epsilon = meshDiag × 1e-5) so adjacent triangles split by Unity + /// UV/normal seams still share edges — without this, ExtractShells sees + /// ~3× too many shells on curved geometry. + /// reports tris dropped (their Face3D.area is set to 0 so callers can + /// skip them via a sentinel marker instead of producing zero-area + /// shells). + static Face3D[] BuildFaceData(Mesh mesh, Transform xform, float meshDiag, + out Vector3[] worldVerts, out int[] rawTris, out int[] canonicalTris, + out int degenerateCount) + { + var localVerts = mesh.vertices; + worldVerts = new Vector3[localVerts.Length]; + for (int i = 0; i < localVerts.Length; i++) + worldVerts[i] = xform.TransformPoint(localVerts[i]); + rawTris = mesh.triangles; + canonicalTris = BuildCanonicalIndices(worldVerts, rawTris, meshDiag); + + int n = rawTris.Length / 3; + var data = new Face3D[n]; + int degenerate = 0; + for (int f = 0; f < n; f++) + { + var a = worldVerts[rawTris[f * 3]]; + var b = worldVerts[rawTris[f * 3 + 1]]; + var c = worldVerts[rawTris[f * 3 + 2]]; + data[f].centroid = (a + b + c) / 3f; + var cross = Vector3.Cross(b - a, c - a); + float mag = cross.magnitude; + if (mag > 1e-12f) + { + data[f].normal = cross / mag; + data[f].area = mag * 0.5f; + } + else + { + data[f].normal = Vector3.up; + data[f].area = 0f; + degenerate++; + } + } + degenerateCount = degenerate; + return data; + } + + /// Quantize world-space vertex positions onto a grid of cell + /// size (meshDiag × 1e-5) and return a rewritten triangle-index array + /// where each corner references the canonical ID of its grid cell. + /// Adjacent triangles that share a 3D edge but reference different + /// mesh.vertices entries (UV/normal seam duplicates) collapse onto + /// the same canonical edge. Output length == tris.Length. + static int[] BuildCanonicalIndices(Vector3[] worldVerts, int[] tris, float meshDiag) + { + int vn = worldVerts.Length; + // Per-vertex canonical ID; -1 until assigned. + var canonical = new int[vn]; + for (int i = 0; i < vn; i++) canonical[i] = -1; + + float cell = Mathf.Max(meshDiag, 1f) * 1e-5f; + float invCell = 1f / cell; + // Use a ValueTuple key so coords aren't bit-packed (21-bit packing + // wraps for meshes far from world origin — a 1m-diag mesh at world + // position 50m generates kx ≈ 5e6, well past 2²¹ ≈ 2M). + var grid = new Dictionary<(long, long, long), int>(vn); + int next = 0; + // Only canonicalize vertices actually used by triangles — unused + // mesh.vertices entries (common in stripped meshes) would + // otherwise pollute the grid. + for (int t = 0; t < tris.Length; t++) + { + int vi = tris[t]; + if (canonical[vi] >= 0) continue; + var p = worldVerts[vi]; + var key = ((long)Mathf.Floor(p.x * invCell), + (long)Mathf.Floor(p.y * invCell), + (long)Mathf.Floor(p.z * invCell)); + if (!grid.TryGetValue(key, out int id)) + { + id = next++; + grid[key] = id; + } + canonical[vi] = id; + } + // Rewrite tris through the canonical map so the caller can index + // it directly as canonicalTris[f*3 + k]. + var rewritten = new int[tris.Length]; + for (int t = 0; t < tris.Length; t++) + rewritten[t] = canonical[tris[t]]; + return rewritten; + } + + static float ComputeMeshDiagonal(Mesh mesh, Transform xform) + { + var verts = mesh.vertices; + if (verts == null || verts.Length == 0) return 0f; + var p0 = xform.TransformPoint(verts[0]); + Vector3 lo = p0, hi = p0; + for (int i = 1; i < verts.Length; i++) + { + var p = xform.TransformPoint(verts[i]); + lo = Vector3.Min(lo, p); + hi = Vector3.Max(hi, p); + } + return (hi - lo).magnitude; + } + + // ─── Shell extraction (union-find on face adjacency) ───────── + + static Shell3D[] ExtractShells(Face3D[] faces, Vector3[] worldVerts, int[] rawTris, + int[] canonicalTris, float thresholdDeg, float shellMergeAngleDeg) + { + var faceToShell = new int[faces.Length]; + return ExtractShells(faces, worldVerts, rawTris, canonicalTris, thresholdDeg, + shellMergeAngleDeg, faceToShell, null); + } + + /// Variant that also fills with the + /// shell index per face (or -1 for degenerate faces / faces excluded by + /// ). The array must be pre-allocated + /// to faces.Length. must contain + /// position-deduplicated vertex indices (see ). + /// (optional, may be null): only faces + /// with mask[f] == true participate in shell formation; the rest get + /// faceToShellOut[f] = -1. Used to cluster the subset of fine-LOD faces + /// flagged for promotion by the projective classifier. + /// (≤0 disables) controls the + /// second-pass merge of adjacent shells whose dominant normals agree + /// within this many degrees — see . + static Shell3D[] ExtractShells(Face3D[] faces, Vector3[] worldVerts, int[] rawTris, + int[] canonicalTris, float thresholdDeg, float shellMergeAngleDeg, + int[] faceToShellOut, bool[] participateMask) + { + int n = faces.Length; + if (n == 0) return new Shell3D[0]; + // Adjacency: edge → face list. Edge key packs (min(va,vb), max). + // Degenerate faces (area==0) contribute no edges — they remain + // singleton "roots" but get filtered to faceToShellOut = -1 below. + var edgeFaces = new Dictionary>(n * 3); + void AddEdge(int va, int vb, int face) + { + long key = va < vb + ? ((long)va << 32) | (uint)vb + : ((long)vb << 32) | (uint)va; + if (!edgeFaces.TryGetValue(key, out var list)) + { + list = new List(2); + edgeFaces[key] = list; + } + list.Add(face); + } + for (int f = 0; f < n; f++) + { + if (faces[f].area <= 0f) continue; + if (participateMask != null && !participateMask[f]) continue; + int v0 = canonicalTris[f * 3], v1 = canonicalTris[f * 3 + 1], v2 = canonicalTris[f * 3 + 2]; + AddEdge(v0, v1, f); AddEdge(v1, v2, f); AddEdge(v2, v0, f); + } + + float thresholdCos = Mathf.Cos(thresholdDeg * Mathf.Deg2Rad); + var parent = new int[n]; + for (int i = 0; i < n; i++) parent[i] = i; + int Find(int x) + { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + } + void Union(int a, int b) + { + int ra = Find(a), rb = Find(b); + if (ra != rb) parent[ra] = rb; + } + foreach (var kv in edgeFaces) + { + var list = kv.Value; + if (list.Count < 2) continue; + for (int i = 0; i < list.Count; i++) + for (int j = i + 1; j < list.Count; j++) + { + float d = Vector3.Dot(faces[list[i]].normal, faces[list[j]].normal); + if (d >= thresholdCos) Union(list[i], list[j]); + } + } + + // Materialise shells with area-weighted centroid + normal, plus + // a local 2D basis derived from the dominant normal (used by + // InverseTransfer to project 3D verts into the domain's atlas rect). + var rootToShell = new Dictionary(); + var faces_ = new List>(); + var accumNormal = new List(); + var accumCentroid = new List(); + var accumArea = new List(); + + for (int f = 0; f < n; f++) + { + if (faces[f].area <= 0f || + (participateMask != null && !participateMask[f])) + { + faceToShellOut[f] = -1; + continue; + } + int r = Find(f); + if (!rootToShell.TryGetValue(r, out int si)) + { + si = faces_.Count; + rootToShell[r] = si; + faces_.Add(new List()); + accumNormal.Add(Vector3.zero); + accumCentroid.Add(Vector3.zero); + accumArea.Add(0f); + } + faceToShellOut[f] = si; + faces_[si].Add(f); + float a = faces[f].area; + accumNormal[si] = accumNormal[si] + faces[f].normal * a; + accumCentroid[si] = accumCentroid[si] + faces[f].centroid * a; + accumArea[si] = accumArea[si] + a; + } + + var shells = new Shell3D[faces_.Count]; + for (int si = 0; si < shells.Length; si++) + { + shells[si].faceIndices = faces_[si]; + shells[si].faceCount = faces_[si].Count; + float ta = accumArea[si]; + shells[si].totalArea = ta; + if (ta > 1e-12f) + { + shells[si].centroid = accumCentroid[si] / ta; + var nn = accumNormal[si] / ta; + float m = nn.magnitude; + shells[si].dominantNormal = m > 1e-12f ? nn / m : Vector3.up; + } + else + { + shells[si].dominantNormal = Vector3.up; + shells[si].centroid = Vector3.zero; + } + ComputePlaneBasis(shells[si].dominantNormal, out shells[si].basisU, out shells[si].basisV); + ComputeExtents(worldVerts, rawTris, faces_[si], shells[si].centroid, + shells[si].basisU, shells[si].basisV, + out shells[si].extentU, out shells[si].extentV); + } + + // Optional Step 2 merge: collapse adjacent shells whose dominant + // normals are within shellMergeAngleDeg. The caller passes a + // negative or zero threshold (or omits the overload) to disable. + if (shellMergeAngleDeg > 0f && shells.Length > 1) + shells = MergeAdjacentShells(shells, faces, worldVerts, rawTris, + canonicalTris, faceToShellOut, shellMergeAngleDeg); + + return shells; + } + + /// Second-pass shell merge: union-find over shell indices, with + /// adjacency = "shells share at least one canonical edge" and the + /// merge predicate = "shells' dominant normals are within + /// ". Compensates for the per-face + /// extraction being too strict at shell boundaries — a single noisy + /// triangle on a planked wall can otherwise split it into two base + /// shells, breaking face-consensus for any fine-LOD face that + /// straddles the split. Adjacency requirement is critical: it prevents + /// merging two physically separate shells that happen to face the + /// same direction (floor and table top both normal=+Y, but never + /// share an edge). + static Shell3D[] MergeAdjacentShells(Shell3D[] shells, Face3D[] faces, + Vector3[] worldVerts, int[] rawTris, int[] canonicalTris, + int[] faceToShellOut, float mergeAngleDeg) + { + // Build shell-adjacency set via canonical edges. An edge belongs + // to a shell if any of its incident faces does; two shells are + // adjacent if they both claim the same canonical edge. + int n = faces.Length; + var edgeShell = new Dictionary(n * 3); + var adjPairs = new HashSet(); + for (int f = 0; f < n; f++) + { + int s = faceToShellOut[f]; + if (s < 0) continue; + for (int k = 0; k < 3; k++) + { + int va = canonicalTris[f * 3 + k]; + int vb = canonicalTris[f * 3 + (k + 1) % 3]; + long ekey = va < vb + ? ((long)va << 32) | (uint)vb + : ((long)vb << 32) | (uint)va; + if (edgeShell.TryGetValue(ekey, out int other)) + { + if (other != s) + { + long pair = s < other + ? ((long)s << 32) | (uint)other + : ((long)other << 32) | (uint)s; + adjPairs.Add(pair); + } + } + else edgeShell[ekey] = s; + } + } + if (adjPairs.Count == 0) return shells; + + // Union-find over shells with angular threshold. + var parent = new int[shells.Length]; + for (int i = 0; i < shells.Length; i++) parent[i] = i; + int Find(int x) + { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + } + float cosThr = Mathf.Cos(mergeAngleDeg * Mathf.Deg2Rad); + foreach (long pair in adjPairs) + { + int s1 = (int)(pair >> 32); + int s2 = (int)(pair & 0xFFFFFFFFL); + float dot = Vector3.Dot(shells[s1].dominantNormal, + shells[s2].dominantNormal); + if (dot >= cosThr) + { + int r1 = Find(s1), r2 = Find(s2); + if (r1 != r2) parent[r1] = r2; + } + } + + // Compact: build new shell list, one entry per unique root. + var rootToNew = new Dictionary(); + var newFaceLists = new List>(); + int[] oldToNew = new int[shells.Length]; + for (int i = 0; i < shells.Length; i++) + { + int r = Find(i); + if (!rootToNew.TryGetValue(r, out int ni)) + { + ni = newFaceLists.Count; + rootToNew[r] = ni; + newFaceLists.Add(new List()); + } + oldToNew[i] = ni; + } + if (newFaceLists.Count == shells.Length) return shells; // no merges + + // Re-thread faces into new shells + update faceToShellOut. + for (int f = 0; f < faceToShellOut.Length; f++) + { + int s = faceToShellOut[f]; + if (s < 0) continue; + int ns = oldToNew[s]; + faceToShellOut[f] = ns; + newFaceLists[ns].Add(f); + } + + // Re-aggregate centroid / normal / extent for each merged shell. + var merged = new Shell3D[newFaceLists.Count]; + for (int ni = 0; ni < merged.Length; ni++) + { + var faceIdx = newFaceLists[ni]; + Vector3 nAccum = Vector3.zero, cAccum = Vector3.zero; + float aAccum = 0f; + foreach (int f in faceIdx) + { + float a = faces[f].area; + nAccum += faces[f].normal * a; + cAccum += faces[f].centroid * a; + aAccum += a; + } + merged[ni].faceIndices = faceIdx; + merged[ni].faceCount = faceIdx.Count; + merged[ni].totalArea = aAccum; + if (aAccum > 1e-12f) + { + merged[ni].centroid = cAccum / aAccum; + var nn = nAccum / aAccum; + float m = nn.magnitude; + merged[ni].dominantNormal = m > 1e-12f ? nn / m : Vector3.up; + } + else + { + merged[ni].dominantNormal = Vector3.up; + merged[ni].centroid = Vector3.zero; + } + ComputePlaneBasis(merged[ni].dominantNormal, + out merged[ni].basisU, out merged[ni].basisV); + ComputeExtents(worldVerts, rawTris, faceIdx, merged[ni].centroid, + merged[ni].basisU, merged[ni].basisV, + out merged[ni].extentU, out merged[ni].extentV); + } + return merged; + } + + // ─── Per-vertex projection (PR-2.7 — Frostbite-style) ──────── + + /// Closest point on triangle ABC to query point P. Standard + /// Voronoi-region algorithm (Ericson, Real-Time Collision Detection + /// ch. 5). No allocations, ~30 ops, branch-heavy. + static Vector3 ClosestPointOnTriangle(Vector3 p, Vector3 a, Vector3 b, Vector3 c) + { + Vector3 ab = b - a, ac = c - a, ap = p - a; + float d1 = Vector3.Dot(ab, ap); + float d2 = Vector3.Dot(ac, ap); + if (d1 <= 0f && d2 <= 0f) return a; + + Vector3 bp = p - b; + float d3 = Vector3.Dot(ab, bp); + float d4 = Vector3.Dot(ac, bp); + if (d3 >= 0f && d4 <= d3) return b; + + float vc = d1 * d4 - d3 * d2; + if (vc <= 0f && d1 >= 0f && d3 <= 0f) + { + float v = d1 / (d1 - d3); + return a + v * ab; + } + + Vector3 cp = p - c; + float d5 = Vector3.Dot(ab, cp); + float d6 = Vector3.Dot(ac, cp); + if (d6 >= 0f && d5 <= d6) return c; + + float vb = d5 * d2 - d1 * d6; + if (vb <= 0f && d2 >= 0f && d6 <= 0f) + { + float w = d2 / (d2 - d6); + return a + w * ac; + } + + float va = d3 * d6 - d5 * d4; + if (va <= 0f && (d4 - d3) >= 0f && (d5 - d6) >= 0f) + { + float w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return b + w * (c - b); + } + + float denom = 1f / (va + vb + vc); + float vv = vb * denom; + float ww = vc * denom; + return a + ab * vv + ac * ww; + } + + /// Squared distance from point q to AABB [mn, mx]; 0 if inside. + /// Used as an early-out filter before the expensive triangle test. + static float SqDistToAabb(Vector3 q, Vector3 mn, Vector3 mx) + { + float dx = q.x < mn.x ? mn.x - q.x : (q.x > mx.x ? q.x - mx.x : 0f); + float dy = q.y < mn.y ? mn.y - q.y : (q.y > mx.y ? q.y - mx.y : 0f); + float dz = q.z < mn.z ? mn.z - q.z : (q.z > mx.z ? q.z - mx.z : 0f); + return dx * dx + dy * dy + dz * dz; + } + + /// Precomputed per-triangle AABBs for the deepest-LOD mesh — + /// pays for itself after ~3 vertex queries vs computing on the fly. + static void BuildDeepAabbs(Vector3[] worldVerts, int[] rawTris, + out Vector3[] mins, out Vector3[] maxs) + { + int n = rawTris.Length / 3; + mins = new Vector3[n]; + maxs = new Vector3[n]; + for (int f = 0; f < n; f++) + { + var a = worldVerts[rawTris[f * 3]]; + var b = worldVerts[rawTris[f * 3 + 1]]; + var c = worldVerts[rawTris[f * 3 + 2]]; + mins[f] = Vector3.Min(Vector3.Min(a, b), c); + maxs[f] = Vector3.Max(Vector3.Max(a, b), c); + } + } + + /// Find the deepest-LOD triangle whose surface is closest to + /// world-space query point . Brute-force scan with + /// AABB rejection — O(N) tris per query, but on our worst test models + /// (~4k deep tris × ~12k fine verts) totals well under a second. A BVH + /// is a follow-up if profiling justifies it. Returns -1 if the deep + /// mesh is empty. + static int ProjectVertexToDeepMesh(Vector3 q, + Face3D[] deepFaces, Vector3[] deepWorldVerts, int[] deepRawTris, + Vector3[] aabbMin, Vector3[] aabbMax, out float bestDist) + { + int closest = -1; + float bestSq = float.MaxValue; + int n = deepFaces.Length; + for (int f = 0; f < n; f++) + { + if (deepFaces[f].area <= 0f) continue; + if (SqDistToAabb(q, aabbMin[f], aabbMax[f]) >= bestSq) continue; + var a = deepWorldVerts[deepRawTris[f * 3]]; + var b = deepWorldVerts[deepRawTris[f * 3 + 1]]; + var c = deepWorldVerts[deepRawTris[f * 3 + 2]]; + Vector3 pt = ClosestPointOnTriangle(q, a, b, c); + float dsq = (pt - q).sqrMagnitude; + if (dsq < bestSq) + { + bestSq = dsq; + closest = f; + } + } + bestDist = closest >= 0 ? Mathf.Sqrt(bestSq) : float.PositiveInfinity; + return closest; + } + + + // ─── Public callable for the unified benchmark orchestrator ── + + /// Build the hierarchical atlas for a single LODGroup and write + /// the dry-run CSV into as + /// repack.csv plus diagnostic PNGs. Returns the build + /// ; the + /// caller can inspect counters or surface a per-case summary. This is + /// the entry point used by LightmapTransferTool.ExecBenchmark; + /// stand-alone single-model dry-runs are no longer wired to a + /// dedicated menu — the unified benchmark covers that workflow. + public static Result BuildAndWriteForCase(LODGroup lg, Options opts, string outputDir) + { + var result = Build(lg, opts); + if (!string.IsNullOrEmpty(result.error)) return result; + // Diagnostic PNG outputs that survived the legacy purge: + // • Stage 1 — proxy_uv2_clean/raw/auto.png + // • Stage B — lodN_classical_uv2.png (per fine LOD) + // • Stage 2/3 — proxy_samples.png + lodN_proxy_hits.png + // Stage 5 single-proxy final_uv2.png, atlas.png, lodN.png + // (legacy domain category map) and the dry-run CSV writer + // were dependencies of the deleted PR-2 pipeline. They will + // come back during plan v2's Stage C/D/E with new layouts. + WriteProxyUv2Png(outputDir, result); + WriteFineClassicalUv2Pngs(outputDir, lg, result); + WriteProxySamplesPng(outputDir, result); + WriteProxyHitsPngs(outputDir, lg, result); + // Stage C: per-LOD 3D shell partition iso views. + WritePerLodShellsPngs(outputDir, lg, result); + // Stage D: per-LOD lighting-domain groups (same colour across + // LODs when cascade matched; new colours for unmatched detail). + WritePerLodGroupsPngs(outputDir, lg, result); + // Stage E (slice E1): the packed shared domain atlas — one chart + // per lighting-domain group, laid out by xatlas. + WriteDomainAtlasPng(outputDir, result); + // Stage E (slice E2): per-LOD cascaded uv2 — same domain → same + // atlas region across LODs. lod{N}_final_uv2.png. + WriteFinalUv2Pngs(outputDir, lg, result); + // Stage E (slice E3): objective metrics CSV + coverage/overlap + // rasters (stage_e_metrics.csv, lod{N}_overlap.png). + WriteStageEMetricsCsv(outputDir, result); + WriteStageEOverlapPngs(outputDir, result); + return result; + } + + /// Stage D threshold sweep — for each + /// (matchFrac × minHits) grid cell, rebuild the LODGroup with those + /// cascade thresholds and append one stage_d_sweep.csv row + /// per LOD transition per cell. No auto-winner — Stage E (which + /// would expose a lightmap-defect scalar) isn't built yet, so the + /// CSV's join/new/tiny ratios are the signal. + /// + /// The CSV is the deliverable. Per-cell group iso-view PNGs + /// (lod{N}_groups_mf{F}_mh{H}.png) are written ONLY when + /// is true — the first sweep run + /// showed those PNGs are near-identical across cells on the major + /// surfaces (variation is sub-pixel trim only), so by default they're + /// suppressed as noise. The canonical visual is the baseline + /// lod{N}_groups.png the hierarchicalRepack technique already + /// writes at the default thresholds. Flip the flag for a deep visual + /// dive (cells × LODs PNGs). + /// + /// Each cell is a full on a fresh + /// , so there is zero cross-cell contamination; + /// the cost is that the cascade-independent stages (xatlas unwraps, + /// sampling) re-run per cell — acceptable for an opt-in diagnostic on + /// a small grid. Returns the last cell's Result (or the error Result + /// if the very first cell failed). + public static Result BuildStageDSweep(LODGroup lg, Options baseOpts, + float[] matchFracGrid, int[] minHitsGrid, string outputDir, + bool emitPerCellPngs = false) + { + if (matchFracGrid == null || matchFracGrid.Length == 0) + matchFracGrid = new[] { baseOpts.cascadeMatchFrac }; + if (minHitsGrid == null || minHitsGrid.Length == 0) + minHitsGrid = new[] { baseOpts.cascadeMinHits }; + + var csv = new System.Text.StringBuilder(); + csv.AppendLine("matchFrac,minHits,groupCount,seedGroups," + + "liProxy,liFine,samples,hits,missed,joined,fresh,reused," + + "tinyJoined,tinyOrphan," + + "e3OverlapTexels,e3OverlapPct,e3TpuSpreadMax,e3XLodMinPct," + + "e3MisalignedGroups,e3UnplacedFaces"); + + Result last = null; + foreach (float mf in matchFracGrid) + foreach (int mh in minHitsGrid) + { + var opts = baseOpts; + opts.cascadeMatchFrac = mf; + opts.cascadeMinHits = mh; + + Result r; + try { r = Build(lg, opts); } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage D sweep cell mf={mf:F2} mh={mh} " + + $"on '{lg.name}' threw: {ex.Message}"); + continue; + } + last = r; + if (!string.IsNullOrEmpty(r.error)) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage D sweep cell mf={mf:F2} mh={mh} " + + $"on '{lg.name}': {r.error}"); + continue; + } + + // Per-cell PNGs are opt-in noise (see method summary) — the + // CSV is the real output. Suffix: period → 'p' so the + // extension split stays unambiguous (lod0_groups_mf0p50_mh4.png). + if (emitPerCellPngs) + { + string suffix = $"_mf{mf.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture).Replace('.', 'p')}_mh{mh}"; + WritePerLodGroupsPngs(outputDir, lg, r, suffix); + } + + int groupCount = r.groups?.Length ?? 0; + int seedGroups = (r.cascadeStats != null && r.cascadeStats.Count > 0) + ? groupCount - SumFresh(r.cascadeStats) // groups present before any fresh adds + : groupCount; + + // Stage E3 cell-level aggregates, repeated on every + // transition row (like groupCount) — the objective scalar + // this sweep previously lacked. Minimise e3OverlapTexels + // first; e3XLodMinPct guards the cascade contract. + int e3Overlap = 0, e3Mis = 0, e3Unplaced = 0; + long e3Covered = 0; + float e3SpreadMax = 0f, e3XMin = 100f; + if (r.stageEMetrics != null) + foreach (var em in r.stageEMetrics) + { + if (em.faces == 0) continue; + e3Overlap += em.overlapTexels; + e3Covered += em.coveredTexels; + e3Mis += em.misalignedGroups; + e3Unplaced += em.unplacedFaces; + if (em.tpuSpread > e3SpreadMax) e3SpreadMax = em.tpuSpread; + if (em.xLodTexels > 0 && em.xLodContainedPct < e3XMin) + e3XMin = em.xLodContainedPct; + } + float e3OverlapPct = e3Covered > 0 + ? 100f * e3Overlap / e3Covered : 0f; + string e3Cols = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "{0},{1:0.###},{2:0.###},{3:0.##},{4},{5}", + e3Overlap, e3OverlapPct, e3SpreadMax, e3XMin, e3Mis, e3Unplaced); + + if (r.cascadeStats != null) + foreach (var st in r.cascadeStats) + csv.AppendLine( + $"{mf.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)}," + + $"{mh},{groupCount},{seedGroups}," + + $"{st.liProxy},{st.liFine},{st.samples},{st.hits}," + + $"{st.missed},{st.joined},{st.fresh},{st.reused}," + + $"{st.tinyJoined},{st.tinyOrphan},{e3Cols}"); + else + csv.AppendLine( + $"{mf.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)}," + + $"{mh},{groupCount},{seedGroups},,,,,,,,,,,{e3Cols}"); + } + + try + { + File.WriteAllText(Path.Combine(outputDir, "stage_d_sweep.csv"), + csv.ToString()); + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage D sweep CSV write failed on '{lg.name}': {ex.Message}"); + } + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage D sweep '{lg.name}': " + + $"{matchFracGrid.Length}×{minHitsGrid.Length} cells → stage_d_sweep.csv" + + (emitPerCellPngs ? " + lod*_groups_mf*_mh*.png" : " (per-cell PNGs suppressed)")); + return last; + } + + static int SumFresh(List stats) + { + int n = 0; + foreach (var s in stats) n += s.fresh; + return n; + } + + /// PR-3 Stage 1 visualization: render up to three proxy UV2 + /// candidates as flat UV layout PNGs for side-by-side comparison. + /// proxy_uv2_clean.png — UV0 → sym-split → xatlas pack (ARAP on) + /// proxy_uv2_raw.png — UV0 → xatlas pack only (no sym-split, + /// no ARAP). Shows what those steps add. + /// proxy_uv2_auto.png — TRUE auto-unwrap from positions + normals + /// (no UV0 input). Needs the native bridge's + /// xatlasAddMesh export (post DLL rebuild). + /// Each PNG is skipped if its variant failed to produce data. + /// Stage E (slice E1) diagnostic: render the packed shared + /// domain atlas (one chart per lighting-domain group) to + /// domains_atlas.png via the shared UV writer. UV is already [0,1]. + static void WriteDomainAtlasPng(string outputDir, Result r) + { + if (r.domainAtlasUv == null || r.domainAtlasTris == null) return; + UvPngWriter.Render(Path.Combine(outputDir, "domains_atlas.png"), + r.domainAtlasUv, r.domainAtlasTris); + } + + /// Stage E (slice E2) diagnostic: render each LOD's cascaded + /// uv2 to lod{N}_final_uv2.png. Same lighting domain → same atlas + /// region across LODs, so the colour/region of a domain should line up + /// from LOD0 down to the deepest. + static void WriteFinalUv2Pngs(string outputDir, LODGroup lg, Result r) + { + if (r.finalUv2 == null || r.finalTris == null) return; + for (int li = 0; li < r.finalUv2.Length; li++) + { + if (r.finalUv2[li] == null || r.finalTris[li] == null) continue; + UvPngWriter.Render(Path.Combine(outputDir, $"lod{li}_final_uv2.png"), + r.finalUv2[li], r.finalTris[li]); + } + } + + /// Stage E3: one CSV row per LOD with the atlas quality + /// metrics — stage_e_metrics.csv (InvariantCulture). + static void WriteStageEMetricsCsv(string outputDir, Result r) + { + if (r.stageEMetrics == null) return; + var csv = new StringBuilder(); + csv.AppendLine("lod,srcFaces,faces,unplacedFaces,degenUvFaces,invertedFaces," + + "oobVerts,coveredTexels,utilPct,overlapTexels,overlapPctOfCovered," + + "overlapShellPairs,tpuMean,tpuP1,tpuP99,tpuSpread," + + "xLodTexels,xLodContainedPct,misalignedGroups"); + var ci = CultureInfo.InvariantCulture; + foreach (var m in r.stageEMetrics) + csv.AppendLine(string.Format(ci, + "{0},{1},{2},{3},{4},{5},{6},{7},{8:0.##},{9},{10:0.###},{11}," + + "{12:0.##},{13:0.##},{14:0.##},{15:0.###},{16},{17:0.##},{18}", + m.lod, m.srcFaces, m.faces, m.unplacedFaces, m.degenUvFaces, + m.invertedFaces, m.oobVerts, m.coveredTexels, m.utilizationPct, + m.overlapTexels, m.overlapPctOfCovered, m.overlapShellPairs, + m.tpuMean, m.tpuP1, m.tpuP99, m.tpuSpread, + m.xLodTexels, m.xLodContainedPct, m.misalignedGroups)); + try + { + File.WriteAllText(Path.Combine(outputDir, "stage_e_metrics.csv"), + csv.ToString()); + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E3 metrics CSV write failed: {ex.Message}"); + } + } + + /// Stage E3 diagnostic: per-LOD coverage/overlap raster — + /// grey covered, red overlapping texels. lod{N}_overlap.png. + static void WriteStageEOverlapPngs(string outputDir, Result r) + { + if (r.stageEOverlapPx == null || r.stageEMetricsRes <= 0) return; + int res = r.stageEMetricsRes; + for (int li = 0; li < r.stageEOverlapPx.Length; li++) + { + if (r.stageEOverlapPx[li] == null) continue; + EncodePng(r.stageEOverlapPx[li], res, res, + Path.Combine(outputDir, $"lod{li}_overlap.png")); + } + } + + static void WriteProxyUv2Png(string outputDir, Result r) + { + if (r.proxyUv2Clean != null && r.proxyTrisClean != null) + UvPngWriter.Render(Path.Combine(outputDir, "proxy_uv2_clean.png"), + r.proxyUv2Clean, r.proxyTrisClean); + if (r.proxyUv2Raw != null && r.proxyTrisRaw != null) + UvPngWriter.Render(Path.Combine(outputDir, "proxy_uv2_raw.png"), + r.proxyUv2Raw, r.proxyTrisRaw); + if (r.proxyUv2Auto != null && r.proxyTrisAuto != null) + UvPngWriter.Render(Path.Combine(outputDir, "proxy_uv2_auto.png"), + r.proxyUv2Auto, r.proxyTrisAuto); + } + + /// Generate the three proxy UV2 candidates documented at + /// . Each populates a pair of + /// (proxyUv2*, proxyTris*) fields on the Result. Variants that + /// fail individually log a warning but don't abort the others. + static void ComputeProxyUv2Variants(Mesh deepMesh, Transform deepXform, + Options opts, string lgName, Result result) + { + if (deepMesh == null) return; + + // ── Variant 1: clean (sym-split + ARAP + pack) ── + if (deepMesh.uv != null && deepMesh.uv.Length > 0) + { + var clone = UnityEngine.Object.Instantiate(deepMesh); + clone.name = deepMesh.name + "_proxy_clean"; + try + { + var shells = UvShellExtractor.Extract(clone.uv, clone.triangles); + if (shells != null && shells.Count > 0) + { + int split = SymmetrySplitShells.Split(clone, shells); + if (split > 0) + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] proxy sym-split on '{lgName}': {split} shells split"); + } + // Call RepackSingle directly (bypass RepackUv) so we can + // disable xatlas's 90° chart rotation. The diagnostic + // operator reads chart orientation as a feature of the + // proxy layout — auto-rotating for ~5% extra packing + // density rotates diagonal triangulations relative to + // the other variants and reads as "разворот". + var cleanOpts = RepackOptions.Default; + cleanOpts.resolution = (uint)opts.atlasResolutionPx; + cleanOpts.padding = (uint)opts.interDomainPaddingPx; + cleanOpts.rotateCharts = false; + var packed = XatlasRepack.RepackSingle(clone, cleanOpts).ok + ? clone.uv2 : null; + if (packed != null && packed.Length > 0) + { + result.proxyUv2Clean = packed; + result.proxyTrisClean = clone.triangles; + result.proxyWorldVertsClean = ToWorld(clone.vertices, deepXform); + } + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] proxy clean variant failed on '{lgName}': {ex.Message}"); + } + finally { UnityEngine.Object.DestroyImmediate(clone); } + } + + // ── Variant 2: raw (UV0 → pack only) ── + if (deepMesh.uv != null && deepMesh.uv.Length > 0) + { + try + { + var rawOpts = RepackOptions.Default; + rawOpts.resolution = (uint)opts.atlasResolutionPx; + rawOpts.padding = (uint)opts.interDomainPaddingPx; + rawOpts.reparameterizeStretchedShells = false; // disable ARAP + rawOpts.rotateCharts = false; // preserve orientation + var clone = UnityEngine.Object.Instantiate(deepMesh); + clone.name = deepMesh.name + "_proxy_raw"; + try + { + var res = XatlasRepack.RepackSingle(clone, rawOpts); + if (res.ok) + { + var uvOut = new List(); + clone.GetUVs(1, uvOut); + result.proxyUv2Raw = uvOut.ToArray(); + result.proxyTrisRaw = clone.triangles; + result.proxyWorldVertsRaw = ToWorld(clone.vertices, deepXform); + } + } + finally { UnityEngine.Object.DestroyImmediate(clone); } + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] proxy raw variant failed on '{lgName}': {ex.Message}"); + } + } + + // ── Variant 3: true auto-unwrap (positions + normals) ── + try + { + AutoUnwrapDeepMesh(deepMesh, deepXform, opts, + out var uvAuto, out var trisAuto, out var worldAuto); + if (uvAuto != null && trisAuto != null && worldAuto != null) + { + result.proxyUv2Auto = uvAuto; + result.proxyTrisAuto = trisAuto; + result.proxyWorldVertsAuto = worldAuto; + } + } + catch (Exception ex) + { + // xatlasAddMesh missing from the DLL = DllNotFoundException / + // EntryPointNotFoundException; treat as "feature not yet + // available", don't spam an Error. + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] proxy auto-unwrap unavailable on '{lgName}': {ex.GetType().Name} ({ex.Message})"); + } + + // ── Select the active proxy that Stage 2+ will consume ── + // All three variants are still emitted as diagnostic PNGs; + // this just decides which pair the downstream sampler reads. + // Fall back to Clean if the selected variant didn't produce + // data (e.g. Auto failed before the DLL rebuild propagated). + switch (opts.proxyMode) + { + case ProxyMode.Raw: + if (result.proxyUv2Raw != null) + { + result.proxyUv2 = result.proxyUv2Raw; + result.proxyTris = result.proxyTrisRaw; + result.proxyWorldVerts = result.proxyWorldVertsRaw; + } + break; + case ProxyMode.Auto: + if (result.proxyUv2Auto != null) + { + result.proxyUv2 = result.proxyUv2Auto; + result.proxyTris = result.proxyTrisAuto; + result.proxyWorldVerts = result.proxyWorldVertsAuto; + } + break; + } + // Default + fallback: Clean. + if (result.proxyUv2 == null && result.proxyUv2Clean != null) + { + result.proxyUv2 = result.proxyUv2Clean; + result.proxyTris = result.proxyTrisClean; + result.proxyWorldVerts = result.proxyWorldVertsClean; + } + } + + /// PR-3 Stage B: run the classical xatlas unwrap + /// (sym-split + ARAP + pack, same params as proxy) on EACH + /// non-deepest LOD independently. Stores per-LOD (uv2, tris) + /// pairs on Result.fineClassicalUv2 / fineClassicalTris. + /// Diagnostic-only at this stage — gives the operator a + /// side-by-side view of how the fine LOD would unwrap if it + /// were unwrapped without any inheritance from the proxy. + /// Stage D's cascade projection will use these layouts as the + /// seed for repacking unmatched shells. + static void ComputeFineClassicalUnwraps(LODGroup lg, Options opts, Result r) + { + if (lg == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + int deepest = lodCount - 1; + while (deepest > 0 && (lods[deepest].renderers == null + || lods[deepest].renderers.Length == 0 + || lods[deepest].renderers[0] == null)) deepest--; + + r.fineClassicalUv2 = new Vector2[lodCount][]; + r.fineClassicalTris = new int[lodCount][]; + + for (int li = 0; li < lodCount; li++) + { + if (li == deepest) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var src = mf != null ? mf.sharedMesh : null; + if (src == null) continue; + if (src.uv == null || src.uv.Length == 0) continue; + + var clone = UnityEngine.Object.Instantiate(src); + clone.name = src.name + "_lod" + li + "_classical"; + try + { + // Same pipeline as the proxy's clean variant: + // sym-split mirror-flipped shells, then pack via + // xatlas at the operator-configured resolution and + // padding. rotateCharts off so chart orientation + // stays comparable across LODs for the diagnostic. + var shells = UvShellExtractor.Extract(clone.uv, clone.triangles); + if (shells != null && shells.Count > 0) + { + int split = SymmetrySplitShells.Split(clone, shells); + if (split > 0) + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] lod{li} classical sym-split on '{lg.name}': {split} shells split"); + } + var packOpts = RepackOptions.Default; + packOpts.resolution = (uint)opts.atlasResolutionPx; + packOpts.padding = (uint)opts.interDomainPaddingPx; + packOpts.rotateCharts = false; + var res = XatlasRepack.RepackSingle(clone, packOpts); + if (res.ok && clone.uv2 != null && clone.uv2.Length > 0) + { + r.fineClassicalUv2[li] = clone.uv2; + r.fineClassicalTris[li] = clone.triangles; + } + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] lod{li} classical unwrap failed on '{lg.name}': {ex.Message}"); + } + finally { UnityEngine.Object.DestroyImmediate(clone); } + } + } + + /// Transform a local-space vertex array into world space + /// using . Helper for proxy variants that + /// need to expose their post-mod vertex positions to Stage 2+ — + /// sym-split (clean) and chart-seam splitting (auto) both rewrite + /// the proxy vertex layout, so we can't reuse the original deep + /// mesh's world verts. + static Vector3[] ToWorld(Vector3[] local, Transform xform) + { + if (local == null) return null; + var w = new Vector3[local.Length]; + for (int i = 0; i < local.Length; i++) w[i] = xform.TransformPoint(local[i]); + return w; + } + + // ─── PR-3 Stage 2: Poisson sampling on proxy surface ───────── + + /// Generate uniform-density samples across the active + /// proxy's 3D surface using stratified jittered barycentrics per + /// triangle (a close-enough Poisson approximation for the + /// diagnostic; true blue-noise can replace this later without + /// changing call sites). Each sample carries the world position + /// and the proxy UV2 it inherits — Stage 3 will project these + /// onto each fine LOD's surface and use the {3D, UV2} pairs to + /// fit a per-fine-shell affine transform. + static void GenerateProxySamples(Options opts, float meshDiag, Result r) + { + if (r.proxyUv2 == null || r.proxyTris == null || r.proxyWorldVerts == null) + return; + if (r.proxyTris.Length < 3) return; + + // Per-face proxy-shell membership via UV2-edge connectivity. + // UvShellExtractor was written for UV0 input, but the partition + // rule (two faces share a shell iff their shared canonical edge + // has matching UV on both sides) is the same on UV2. + var uvShells = UvShellExtractor.Extract(r.proxyUv2, r.proxyTris); + int faceCount = r.proxyTris.Length / 3; + var faceToShell = new int[faceCount]; + for (int i = 0; i < faceCount; i++) faceToShell[i] = -1; + if (uvShells != null) + { + for (int si = 0; si < uvShells.Count; si++) + foreach (int f in uvShells[si].faceIndices) + if (f >= 0 && f < faceCount) + faceToShell[f] = si; + } + + float diagSq = Mathf.Max(meshDiag * meshDiag, 1e-6f); + float densityPerSqMeshDiag = Mathf.Max(opts.proxySampleDensity, 1); + var samples = new List(faceCount * 6); + int nextSampleId = 0; + // Use a deterministic LCG so re-runs on the same mesh produce + // identical sample sets — keeps the visual diagnostic stable. + uint rng = 0x9E3779B9u; + + // Pre-pass: compute each tri's UV bbox so the per-tri rogue + // filter below can read it without recomputing. Adaptive + // (median-relative) filter was removed -- it penalised + // assets with heterogeneous chart sizes (many thin detail + // charts + few large panel charts), where the median falls + // to the thin-chart bbox and the large panel charts get + // filtered as 'rogue'. Only the absolute cap remains. + var triUvBboxMax = new float[faceCount]; + for (int f = 0; f < faceCount; f++) + { + int ia = r.proxyTris[f * 3]; + int ib = r.proxyTris[f * 3 + 1]; + int ic = r.proxyTris[f * 3 + 2]; + Vector2 uvA = r.proxyUv2[ia]; + Vector2 uvB = r.proxyUv2[ib]; + Vector2 uvC = r.proxyUv2[ic]; + float minX = Mathf.Min(uvA.x, Mathf.Min(uvB.x, uvC.x)); + float maxX = Mathf.Max(uvA.x, Mathf.Max(uvB.x, uvC.x)); + float minY = Mathf.Min(uvA.y, Mathf.Min(uvB.y, uvC.y)); + float maxY = Mathf.Max(uvA.y, Mathf.Max(uvB.y, uvC.y)); + triUvBboxMax[f] = Mathf.Max(maxX - minX, maxY - minY); + } + + for (int f = 0; f < faceCount; f++) + { + int ia = r.proxyTris[f * 3]; + int ib = r.proxyTris[f * 3 + 1]; + int ic = r.proxyTris[f * 3 + 2]; + Vector3 A = r.proxyWorldVerts[ia]; + Vector3 B = r.proxyWorldVerts[ib]; + Vector3 C = r.proxyWorldVerts[ic]; + Vector2 uvA = r.proxyUv2[ia]; + Vector2 uvB = r.proxyUv2[ib]; + Vector2 uvC = r.proxyUv2[ic]; + + // Skip rogue tris from xatlas chart-seam splits (one + // corner pinned at a different chart's UV). Only the + // absolute cap is used — the previous adaptive (8x + // median) cap punished assets with heterogeneous chart + // sizes: a model with many thin detail charts + a few + // large panel charts has a low median, the adaptive + // threshold falls below the legit large charts' bbox, + // and the BIG panels lose all their Poisson samples + // (visible on Wooden_Box_Long where the four large + // square panels had zero pink dots). + const float kUvSeamBboxAbs = 0.35f; + if (triUvBboxMax[f] > kUvSeamBboxAbs) continue; + + Vector3 cross = Vector3.Cross(B - A, C - A); + float crossMag = cross.magnitude; + float triArea = 0.5f * crossMag; + if (triArea <= 0f) continue; + Vector3 faceNormal = cross / crossMag; + int shellId = faceToShell[f]; + + int n = Mathf.Max(3, Mathf.CeilToInt(densityPerSqMeshDiag * triArea / diagSq)); + + for (int s = 0; s < n; s++) + { + rng = unchecked(rng * 1664525u + 1013904223u); + float u = (rng & 0xFFFFFFu) / 16777216f; + rng = unchecked(rng * 1664525u + 1013904223u); + float v = (rng & 0xFFFFFFu) / 16777216f; + // Uniform sampling on a triangle via the + // sqrt-of-uniform reflection. + if (u + v > 1f) { u = 1f - u; v = 1f - v; } + float w = 1f - u - v; + samples.Add(new ProxySample + { + worldPos = A * w + B * u + C * v, + worldNormal = faceNormal, + uv2 = uvA * w + uvB * u + uvC * v, + proxyFaceIdx = f, + proxyShellId = shellId, + sampleId = nextSampleId++, + }); + } + } + r.proxySamples = samples.ToArray(); + } + + /// PR-3 Stage 2 visualization: overlay the proxy samples + /// as small dots on the active proxy's UV2 layout. The base + /// triangulation is drawn faintly so the sample distribution is + /// the dominant visual signal. + static void WriteProxySamplesPng(string outputDir, Result r) + { + if (r.proxySamples == null || r.proxySamples.Length == 0) return; + if (r.proxyUv2 == null || r.proxyTris == null) return; + + // Base: render the active proxy's UV layout as a faded backdrop. + string basePath = Path.Combine(outputDir, "proxy_uv2_active.png"); + UvPngWriter.Render(basePath, r.proxyUv2, r.proxyTris); + + // Now draw samples on top of a fresh canvas with the same + // backdrop. Software path: load the rendered base, draw dots. + int size = UvPngWriter.DefaultSize; + var pixels = new Color32[size * size]; + byte[] basePng = File.ReadAllBytes(basePath); + var baseTex = new Texture2D(2, 2, TextureFormat.RGBA32, false); + try + { + baseTex.LoadImage(basePng); + var basePixels = baseTex.GetPixels32(); + // Render may produce different dimensions; just trust loaded size. + int w = baseTex.width, h = baseTex.height; + if (w * h == basePixels.Length && w == size && h == size) + pixels = basePixels; + else + { + var bg = new Color32(244, 244, 248, 255); + for (int i = 0; i < pixels.Length; i++) pixels[i] = bg; + } + } + finally { UnityEngine.Object.DestroyImmediate(baseTex); } + + // Draw each sample as a 3-pixel disk in bright magenta. The + // UV→pixel mapping MUST match UvPngWriter — it renders the + // [-0.1, 1.1] UV range (to show out-of-bounds verts around + // the unit box), not [0,1] linearly. If we map dots [0,1] → + // [0, size] the points land outside the chart region rendered + // by the backdrop. Mirror the helper's UvLo / UvHi constants. + // V-axis: UvPngWriter draws with GL.LoadPixelMatrix(0,size,0,size) + // where screen Y=0 is bottom; ReadPixels copies that to texture + // pixels[0..size-1], and EncodeToPNG writes pixels forward so + // pixels[0] lands at the TOP of the displayed PNG. To stay aligned + // with the backdrop, dots must use py = ny * size (NO extra flip) + // — the earlier `1 - ny` mirrored every dot across the horizontal + // midline, leaving fan-spokes pointing the wrong way on + // asymmetric charts (visible on Gazebo's keystone shells). + const float kUvLo = -0.1f, kUvHi = 1.1f; + float uvRange = kUvHi - kUvLo; + Color32 dot = new Color32(220, 30, 180, 255); + foreach (var sm in r.proxySamples) + { + float nx = (sm.uv2.x - kUvLo) / uvRange; + float ny = (sm.uv2.y - kUvLo) / uvRange; + int px = Mathf.Clamp(Mathf.FloorToInt(nx * size), 1, size - 2); + int py = Mathf.Clamp(Mathf.FloorToInt(ny * size), 1, size - 2); + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + pixels[(py + dy) * size + (px + dx)] = dot; + } + + string path = Path.Combine(outputDir, "proxy_samples.png"); + EncodePng(pixels, size, size, path); + } + + // ─── PR-3 Stage 3: project proxy samples onto each fine LOD ─── + + /// For every fine LOD: closest-point each proxy sample + /// onto its mesh surface, bin hits per face. Stage 4 will read + /// the per-face dominant proxy shell + hit count to fit a + /// per-fine-shell affine (overlay candidate) or fall through to + /// promote when coverage is sparse. Skipped for the deepest LOD + /// (proxy ≡ deepest, no projection needed) and for LODs without + /// a renderer. + static void ProjectProxySamplesOntoFineLods(LODGroup lg, Options opts, + float meshDiag, Result r) + { + if (r.proxySamples == null || r.proxySamples.Length == 0) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + int deepest = lodCount - 1; + while (deepest > 0 && (lods[deepest].renderers == null + || lods[deepest].renderers.Length == 0 + || lods[deepest].renderers[0] == null)) deepest--; + + r.lodProjections = new FineLodProjection[lodCount]; + float distAbsThreshold = opts.overlayDistNorm * meshDiag; + + for (int li = 0; li < lodCount; li++) + { + if (li == deepest) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + var xform = rs[0].transform; + + var fineFaces = BuildFaceData(mesh, xform, meshDiag, + out Vector3[] fineWorldVerts, out int[] fineRawTris, + out _, out _); + int faceCount = fineFaces.Length; + if (faceCount == 0) continue; + BuildDeepAabbs(fineWorldVerts, fineRawTris, + out var fineMin, out var fineMax); + + var proj = new FineLodProjection + { + perFaceHitCount = new int[faceCount], + perFaceDominantProxyShell = new int[faceCount], + perFaceAvgDist = new float[faceCount], + perFaceHitSampleIdx = new int[faceCount][], + }; + for (int f = 0; f < faceCount; f++) + proj.perFaceDominantProxyShell[f] = -1; + + // Per-face tally of (sampleIdx, proxyShellId) hits. Sample + // indices are persisted in proj.perFaceHitSampleIdx so + // Stage 4 can re-walk them per fine shell without redoing + // the closest-face search; shell IDs are kept locally for + // the dominant-shell vote. + var hitsByFace = new List[faceCount]; // sample indices + var shellsByFace = new List[faceCount]; // proxy shell ids per hit + + for (int sIdx = 0; sIdx < r.proxySamples.Length; sIdx++) + { + var sm = r.proxySamples[sIdx]; + int closestFace = ProjectVertexToDeepMesh(sm.worldPos, + fineFaces, fineWorldVerts, fineRawTris, + fineMin, fineMax, out float dist); + if (closestFace < 0 || dist > distAbsThreshold) + { + proj.missedSamples++; + continue; + } + proj.perFaceHitCount[closestFace]++; + proj.perFaceAvgDist[closestFace] += dist; + if (hitsByFace[closestFace] == null) + { + hitsByFace[closestFace] = new List(4); + shellsByFace[closestFace] = new List(4); + } + hitsByFace[closestFace].Add(sIdx); + shellsByFace[closestFace].Add(sm.proxyShellId); + proj.totalHits++; + } + + // Resolve dominant proxy shell per face + finalize avg dist + // + freeze the per-face sample list as an int[] (cheaper + // than retaining the List for the lifetime of Result). + var shellTally = new Dictionary(); + for (int f = 0; f < faceCount; f++) + { + if (proj.perFaceHitCount[f] == 0) continue; + proj.perFaceAvgDist[f] /= proj.perFaceHitCount[f]; + proj.perFaceHitSampleIdx[f] = hitsByFace[f].ToArray(); + var shells = shellsByFace[f]; + shellTally.Clear(); + foreach (int sh in shells) + { + shellTally.TryGetValue(sh, out int v); + shellTally[sh] = v + 1; + } + int bestShell = -1, bestCount = 0; + foreach (var kv in shellTally) + if (kv.Value > bestCount) { bestCount = kv.Value; bestShell = kv.Key; } + proj.perFaceDominantProxyShell[f] = bestShell; + } + r.lodProjections[li] = proj; + } + } + + // ─── Stage C: per-LOD 3D shell extract + group seed ────────── + + /// For each LOD in the LODGroup, run BuildFaceData + + /// ExtractShells with the same parameters (so shells across + /// LODs come out comparable). Fills r.perLodShells and + /// r.perLodFaceToShell. Then seeds r.groups: each + /// shell on the deepest LOD becomes its own LightingDomainGroup + /// with canonicalLod = deepest. Finer LODs' shells are left + /// unassigned (perLodShellToGroup[li] = all -1) — Stage D's + /// cascade matcher fills them in. + static void ExtractPerLodShellsAndSeedGroups(LODGroup lg, Options opts, + float meshDiag, Result r) + { + if (lg == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + r.perLodShells = new Shell3D[lodCount][]; + r.perLodFaceToShell = new int[lodCount][]; + r.perLodShellToGroup = new int[lodCount][]; + + // Pick deepest LOD (last that has a renderer + mesh). + int deepest = lodCount - 1; + while (deepest > 0) + { + var rs = lods[deepest].renderers; + if (rs != null && rs.Length > 0 && rs[0] != null + && rs[0].GetComponent()?.sharedMesh != null) break; + deepest--; + } + + for (int li = 0; li < lodCount; li++) + { + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + var xform = rs[0].transform; + + int faceCount = mesh.triangles.Length / 3; + if (faceCount == 0) continue; + + var faces = BuildFaceData(mesh, xform, meshDiag, + out Vector3[] worldVerts, out int[] rawTris, + out int[] canonicalTris, out _); + var faceToShell = new int[faces.Length]; + var shells = ExtractShells(faces, worldVerts, rawTris, + canonicalTris, opts.shellNormalThresholdDeg, + opts.shellMergeAngleDeg, faceToShell, null); + + r.perLodShells[li] = shells; + r.perLodFaceToShell[li] = faceToShell; + r.perLodShellToGroup[li] = new int[shells.Length]; + for (int s = 0; s < shells.Length; s++) + r.perLodShellToGroup[li][s] = -1; + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage C: LOD{li} '{lg.name}' → {shells.Length} shells"); + } + + // Seed groups from deepest LOD's shells. + var deepShells = r.perLodShells[deepest]; + if (deepShells == null || deepShells.Length == 0) + { + r.groups = new LightingDomainGroup[0]; + return; + } + var groups = new LightingDomainGroup[deepShells.Length]; + var deepShellToGroup = r.perLodShellToGroup[deepest]; + for (int si = 0; si < deepShells.Length; si++) + { + groups[si] = new LightingDomainGroup + { + groupId = si, + canonicalLod = deepest, + canonicalShellId = si, + members = new List<(int, int)> { (deepest, si) }, + }; + deepShellToGroup[si] = si; + } + r.groups = groups; + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage C: '{lg.name}' seeded {groups.Length} groups " + + $"(canonical=LOD{deepest}); finer LOD shells await Stage D matching"); + } + + // ─── Stage D: cascade grouping deep → fine ───────────────────── + + /// + /// Stage D — cascade grouping deepest → LOD0. Walks each LOD + /// transition (li_proxy, li_proxy-1) deep-side-first, Poisson- + /// samples the deeper LOD's surface tagged with the deeper LOD's + /// SHELL id, projects the samples onto the finer LOD's geometry + /// (closest-point-on-face), and uses the per-shell vote tally to + /// decide whether each finer shell joins the parent's lighting + /// domain group or starts its own. + /// + /// Membership only — NO UV is written here. Stage E will pick + /// each domain's canonical chart and pack the final atlas; this + /// stage only fills r.perLodShellToGroup and grows + /// r.groups. + /// + /// Match rule: per finer shell, look up + /// perLodFaceToShell[li_proxy-1][f] for every face that + /// received hits, weighted by hit count → modal proxy shell + + /// its hit fraction. If the modal fraction ≥ kMatchFrac + /// AND there are at least kMinHits total hits, join the + /// parent's group; otherwise create a new group whose canonical + /// member is the finer shell itself (cluster of detail geometry + /// missing from the deeper LOD — windows, screws, trim). + /// + static void CascadeGroupShells(LODGroup lg, Options opts, + float meshDiag, Result r) + { + if (lg == null || r == null) return; + if (r.perLodShells == null || r.perLodFaceToShell == null + || r.perLodShellToGroup == null || r.groups == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + + // Same deepest-LOD picker as Stage C — must agree so the + // seed groups Stage C wrote line up with our cascade start. + int deepest = lodCount - 1; + while (deepest > 0) + { + var rs = lods[deepest].renderers; + if (rs != null && rs.Length > 0 && rs[0] != null + && rs[0].GetComponent()?.sharedMesh != null) break; + deepest--; + } + if (deepest <= 0) return; + + // Stage E will read r.groups as an array — but cascade may add + // unmatched groups for finer LODs as we descend, so work on a + // growing list and snapshot back to array at the end. + var groups = new List(r.groups); + r.cascadeStats = new List(); + + // Tuning knobs — now in Options so the Stage-D threshold sweep + // can vary them per cell. Defaults (0.5 / 4) match plan v2 + // §"Дыра 6 matched threshold". A non-positive minHits would let + // zero-hit shells join on a 0/0 vote; clamp to 1. + float kMatchFrac = Mathf.Clamp01(opts.cascadeMatchFrac); + int kMinHits = Mathf.Max(1, opts.cascadeMinHits); + float distAbsThreshold = opts.overlayDistNorm * meshDiag; + + for (int liProxy = deepest; liProxy >= 1; liProxy--) + { + int liFine = liProxy - 1; + + var proxyShells = r.perLodShells[liProxy]; + var fineShells = r.perLodShells[liFine]; + var proxyFaceToShell = r.perLodFaceToShell[liProxy]; + var fineFaceToShell = r.perLodFaceToShell[liFine]; + if (proxyShells == null || fineShells == null + || proxyFaceToShell == null || fineFaceToShell == null) continue; + + var rsProxy = lods[liProxy].renderers; + var rsFine = lods[liFine].renderers; + if (rsProxy == null || rsProxy.Length == 0 || rsProxy[0] == null) continue; + if (rsFine == null || rsFine .Length == 0 || rsFine [0] == null) continue; + var mfProxy = rsProxy[0].GetComponent(); + var mfFine = rsFine [0].GetComponent(); + var proxyMesh = mfProxy != null ? mfProxy.sharedMesh : null; + var fineMesh = mfFine != null ? mfFine .sharedMesh : null; + if (proxyMesh == null || fineMesh == null) continue; + + // 1) Sample the deeper LOD's surface, tagged with deeper-LOD + // SHELL id (not UV-derived). Uses the same density knob + // as the existing GenerateProxySamples — that one drives + // UV-aware projection on the deepest LOD only, this one + // drives shell-membership voting between adjacent LODs. + var proxyFaces = BuildFaceData(proxyMesh, rsProxy[0].transform, + meshDiag, out Vector3[] proxyWorld, out int[] proxyRawTris, + out _, out _); + int proxyFaceCount = proxyFaces.Length; + if (proxyFaceCount == 0) continue; + + float diagSq = Mathf.Max(meshDiag * meshDiag, 1e-6f); + float densityPerSqMeshDiag = Mathf.Max(opts.proxySampleDensity, 1); + var samples = new List<(Vector3 pos, int shellId)>(proxyFaceCount * 6); + uint rng = 0x9E3779B9u; + + for (int f = 0; f < proxyFaceCount; f++) + { + if (proxyFaces[f].area <= 0f) continue; + int shellId = (f < proxyFaceToShell.Length) ? proxyFaceToShell[f] : -1; + if (shellId < 0) continue; + + Vector3 A = proxyWorld[proxyRawTris[f * 3]]; + Vector3 B = proxyWorld[proxyRawTris[f * 3 + 1]]; + Vector3 C = proxyWorld[proxyRawTris[f * 3 + 2]]; + + int n = Mathf.Max(3, Mathf.CeilToInt( + densityPerSqMeshDiag * proxyFaces[f].area / diagSq)); + for (int s = 0; s < n; s++) + { + rng = unchecked(rng * 1664525u + 1013904223u); + float u = (rng & 0xFFFFFFu) / 16777216f; + rng = unchecked(rng * 1664525u + 1013904223u); + float v = (rng & 0xFFFFFFu) / 16777216f; + if (u + v > 1f) { u = 1f - u; v = 1f - v; } + float w = 1f - u - v; + samples.Add((A * w + B * u + C * v, shellId)); + } + } + if (samples.Count == 0) continue; + + // 2) Project samples onto the finer LOD's geometry. For each + // fine face, tally per-deeper-shell hit count so we can + // later aggregate per fine SHELL. + var fineFaces = BuildFaceData(fineMesh, rsFine[0].transform, + meshDiag, out Vector3[] fineWorld, out int[] fineRawTris, + out _, out _); + int fineFaceCount = fineFaces.Length; + if (fineFaceCount == 0) continue; + BuildDeepAabbs(fineWorld, fineRawTris, + out var fineMin, out var fineMax); + + // perFineFace[shellId → hits]. List of dicts keeps total + // memory proportional to "faces that got any hits". + var perFineFace = new Dictionary[fineFaceCount]; + + int totalHits = 0, totalMissed = 0; + foreach (var sm in samples) + { + int closest = ProjectVertexToDeepMesh(sm.pos, + fineFaces, fineWorld, fineRawTris, + fineMin, fineMax, out float dist); + if (closest < 0 || dist > distAbsThreshold) + { + totalMissed++; + continue; + } + var bucket = perFineFace[closest]; + if (bucket == null) + { + bucket = new Dictionary(2); + perFineFace[closest] = bucket; + } + bucket.TryGetValue(sm.shellId, out int cnt); + bucket[sm.shellId] = cnt + 1; + totalHits++; + } + + // 3) Aggregate per finer SHELL. perFineShellTally[fineShellId] + // [proxyShellId] = total hits across the fine shell's faces. + var perFineShellTally = new Dictionary[fineShells.Length]; + for (int s = 0; s < fineShells.Length; s++) + perFineShellTally[s] = new Dictionary(2); + for (int f = 0; f < fineFaceCount; f++) + { + var bucket = perFineFace[f]; + if (bucket == null) continue; + int fineShell = (f < fineFaceToShell.Length) ? fineFaceToShell[f] : -1; + if (fineShell < 0 || fineShell >= fineShells.Length) continue; + var dst = perFineShellTally[fineShell]; + foreach (var kv in bucket) + { + dst.TryGetValue(kv.Key, out int c); + dst[kv.Key] = c + kv.Value; + } + } + + // 4) For each finer shell, decide: join parent group or new. + // Tiny-shell rule (sweep insight 2026-06-03): finer shells + // below skipAreaFrac × totalFineArea AND ≤ skipMaxFaceCount + // get force-joined to their modal proxy parent even when + // matchedFrac / minHits would normally fail them — they're + // the thin trim / wire / fastener that the sweep showed was + // the dominant source of fresh-group explosion (Carousel + // 269→922) without affecting major lighting domains. If a + // tiny shell has NO proxy match at all (bestProxy<0; deeper + // LOD genuinely lacks it), it still opens a fresh group — + // a topological-neighbour fallback would handle that case + // but adds an adjacency build; defer until the data shows + // it's needed. Counted separately in the log. + float totalFineArea = 0f; + for (int s = 0; s < fineShells.Length; s++) + totalFineArea += fineShells[s].totalArea; + float tinyAreaAbs = Mathf.Max(0f, opts.skipAreaFrac) * totalFineArea; + int tinyFaceMax = Mathf.Max(0, opts.skipMaxFaceCount); + + int joined = 0, fresh = 0, tinyJoined = 0, tinyOrphan = 0; + int alreadyAssigned = 0; + var proxyShellToGroup = r.perLodShellToGroup[liProxy]; + var fineShellToGroup = r.perLodShellToGroup[liFine]; + + for (int s = 0; s < fineShells.Length; s++) + { + if (fineShellToGroup[s] >= 0) { alreadyAssigned++; continue; } + + var tally = perFineShellTally[s]; + int shellTotal = 0, bestProxy = -1, bestCount = 0; + foreach (var kv in tally) + { + shellTotal += kv.Value; + if (kv.Value > bestCount) { bestCount = kv.Value; bestProxy = kv.Key; } + } + float matchedFrac = shellTotal > 0 ? (float)bestCount / shellTotal : 0f; + + bool parentResolves = bestProxy >= 0 + && bestProxy < proxyShellToGroup.Length + && proxyShellToGroup[bestProxy] >= 0; + bool normalJoin = parentResolves + && shellTotal >= kMinHits + && matchedFrac >= kMatchFrac; + bool isTiny = fineShells[s].totalArea <= tinyAreaAbs + && fineShells[s].faceCount <= tinyFaceMax; + bool tinyForceJoin = isTiny && parentResolves && !normalJoin; + + if (normalJoin || tinyForceJoin) + { + int gid = proxyShellToGroup[bestProxy]; + fineShellToGroup[s] = gid; + var grp = groups[gid]; + if (grp.members == null) grp.members = new List<(int, int)>(); + grp.members.Add((liFine, s)); + groups[gid] = grp; + if (tinyForceJoin) tinyJoined++; else joined++; + } + else + { + int gid = groups.Count; + groups.Add(new LightingDomainGroup + { + groupId = gid, + canonicalLod = liFine, + canonicalShellId = s, + members = new List<(int, int)> { (liFine, s) }, + }); + fineShellToGroup[s] = gid; + fresh++; + if (isTiny) tinyOrphan++; + } + } + + r.cascadeStats.Add(new CascadeStat + { + liProxy = liProxy, + liFine = liFine, + samples = samples.Count, + hits = totalHits, + missed = totalMissed, + joined = joined, + fresh = fresh, + reused = alreadyAssigned, + tinyJoined = tinyJoined, + tinyOrphan = tinyOrphan, + }); + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage D: '{lg.name}' LOD{liProxy}→LOD{liFine} " + + $"samples={samples.Count} hits={totalHits} missed={totalMissed} " + + $"| joined={joined} tinyJoined={tinyJoined} new={fresh} " + + $"(tinyOrphan={tinyOrphan}) reused={alreadyAssigned} " + + $"(thresholds mf>={kMatchFrac:F2} mh>={kMinHits} " + + $"tinyArea<={opts.skipAreaFrac:F3}×total tinyFaces<={tinyFaceMax})"); + } + + r.groups = groups.ToArray(); + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage D: '{lg.name}' final group count = {r.groups.Length} " + + $"(seeded={r.perLodShells[deepest]?.Length ?? 0} from deepest LOD{deepest})"); + } + + // ─── Stage E (slice E1): pack lighting-domain canonical charts ── + + /// Stage E, slice 1 — define the SHARED atlas layout. For + /// each lighting-domain group, take its canonical (deepest-member) + /// shell — the one that found no deeper match, i.e. the seed we + /// REPACK — project its faces onto the shell's own plane in WORLD + /// UNITS (inU = dot(worldPos − centroid, basisU); inV likewise; NO + /// per-shell normalisation, so proportions are real) and feed it to + /// xatlas as a UV mesh with faceMaterial = groupId. Pack runs at a + /// FIXED texelsPerUnit (sized so all canonical area fits the atlas at + /// ~packEff), so every chart gets identical texel density. Each + /// material is a hard chart boundary → one chart per group. From each + /// group's placed verts we least-squares-fit the affine + /// inU→atlasU / inV→atlasV (r.domainPlacements[groupId]); the + /// canonical reproduces its own placement and + /// reuses the affine for every finer member. (r.domainAtlasRects + /// keeps the bbox for the diagnostic.) + /// + /// This is layout only. The cascade payoff — every LOD's matched + /// shells PROJECTING into their group's rect so one bake is valid + /// across all LODs, and unmatched shells owning their repacked rect — + /// is . xatlas output UV is already + /// normalised [0,1] in this native build (confirmed against + /// proxy_uv2_auto.png). r.domainAtlasUv / r.domainAtlasTris + /// back the domains_atlas.png diagnostic. + static void PackDomainCharts(LODGroup lg, Options opts, float meshDiag, Result r) + { + if (r.groups == null || r.groups.Length == 0) return; + if (r.perLodShells == null) return; + + var lods = lg.GetLODs(); + int lodCount = lods.Length; + + // Lazy per-LOD geometry cache (worldVerts + rawTris). Stage C + // didn't keep these on the Result; recompute on first use. Most + // groups' canonical shells live on the deepest LOD, but fresh / + // tinyOrphan groups born on a finer LOD have their canonical there. + var worldVertsByLod = new Vector3[lodCount][]; + var rawTrisByLod = new int[lodCount][]; + var uv0ByLod = new Vector2[lodCount][]; // authored UV0, indexed like worldVerts + var builtLod = new bool[lodCount]; + void EnsureLodGeometry(int li) + { + if (builtLod[li]) return; + builtLod[li] = true; + if (li < 0 || li >= lodCount) return; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) return; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) return; + BuildFaceData(mesh, rs[0].transform, meshDiag, + out Vector3[] wv, out int[] rt, out _, out _); + worldVertsByLod[li] = wv; + rawTrisByLod[li] = rt; + uv0ByLod[li] = mesh.uv; // Vector2[] sized to vertexCount, or empty + } + + // Build one UV mesh per canonical face. We PRESERVE the shell's own + // authored UV0 — its real unwrap — and only apply a single uniform + // scale + recentre per shell. No planar re-projection (that threw + // the shell's parameterisation away and folded curves). The scale + // S = sqrt(area3D / areaUV0) makes every canonical's UV-area equal + // its 3D area, so a fixed texelsPerUnit at pack time → identical + // texel density everywhere, with the shell shape kept intact. + // faceMaterial = groupId. We record each input vert's group + its + // scaled coord so the readback can fit the per-group affine, and we + // stash (uvc, S) per group so BuildCascadedUv2 can transform finer + // members' UV0 into the same frame. + var uvList = new List(1024); + var idxList = new List(1024); + var faceMatList = new List(512); + var inputVertGroup = new List(1024); // myVertIdx → groupId + var inputVertU = new List(1024); // myVertIdx → scaled uv0.x + var inputVertV = new List(1024); // myVertIdx → scaled uv0.y + var pendingUvc = new Vector2[r.groups.Length]; + var pendingScale = new float[r.groups.Length]; + uint vCounter = 0; + double totalCanonArea = 0.0; + + for (int g = 0; g < r.groups.Length; g++) + { + var grp = r.groups[g]; + int cl = grp.canonicalLod; + if (cl < 0 || cl >= lodCount) continue; + var shellsAtCl = r.perLodShells[cl]; + if (shellsAtCl == null || grp.canonicalShellId < 0 + || grp.canonicalShellId >= shellsAtCl.Length) continue; + EnsureLodGeometry(cl); + var rt = rawTrisByLod[cl]; + var uv0 = uv0ByLod[cl]; + if (rt == null || uv0 == null || uv0.Length == 0) continue; + + var sh = shellsAtCl[grp.canonicalShellId]; + if (sh.faceIndices == null || sh.faceIndices.Count == 0) continue; + + // Preserve the shell's UV0 island: centroid + UV0 area, then a + // single uniform scale to normalise texel density (UV-area → + // 3D-area). Shape untouched. + Vector2 uvc = Vector2.zero; int cornerCount = 0; double uvArea = 0.0; + foreach (int f in sh.faceIndices) + { + if (f < 0 || f * 3 + 2 >= rt.Length) continue; + int a = rt[f * 3], b = rt[f * 3 + 1], c = rt[f * 3 + 2]; + if (a >= uv0.Length || b >= uv0.Length || c >= uv0.Length) continue; + uvc += uv0[a] + uv0[b] + uv0[c]; cornerCount += 3; + uvArea += 0.5 * Mathf.Abs((uv0[b].x - uv0[a].x) * (uv0[c].y - uv0[a].y) + - (uv0[c].x - uv0[a].x) * (uv0[b].y - uv0[a].y)); + } + if (cornerCount == 0) continue; + uvc /= cornerCount; + float S = (uvArea > 1e-12) + ? Mathf.Sqrt(sh.totalArea / (float)uvArea) : 1f; + pendingUvc[grp.groupId] = uvc; + pendingScale[grp.groupId] = S; + totalCanonArea += sh.totalArea; + + foreach (int f in sh.faceIndices) + { + if (f < 0 || f * 3 + 2 >= rt.Length) continue; + int e0 = rt[f * 3], e1 = rt[f * 3 + 1], e2 = rt[f * 3 + 2]; + if (e0 >= uv0.Length || e1 >= uv0.Length || e2 >= uv0.Length) continue; + for (int k = 0; k < 3; k++) + { + int vi = rt[f * 3 + k]; + float u = (uv0[vi].x - uvc.x) * S; // preserved UV0, scaled + float v = (uv0[vi].y - uvc.y) * S; + uvList.Add(u); + uvList.Add(v); + idxList.Add(vCounter); + inputVertGroup.Add(grp.groupId); + inputVertU.Add(u); + inputVertV.Add(v); + vCounter++; + } + faceMatList.Add((uint)grp.groupId); + } + } + + // Fixed texel density: with each shell's UV scaled so UV-area == + // 3D-area, the input UV is effectively in world units, so sizing the + // pack by total canonical 3D area gives every chart the SAME + // texels/unit. + const float kPackEff = 0.5f; + float texelsPerUnit = (totalCanonArea > 1e-9) + ? opts.atlasResolutionPx * Mathf.Sqrt(kPackEff / (float)totalCanonArea) + : 0f; + + int vc = (int)vCounter; + int ic = idxList.Count; + int fc = faceMatList.Count; + if (vc == 0 || ic == 0 || fc == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E: '{lg.name}' no canonical chart geometry — skipped"); + return; + } + + var uvArr = uvList.ToArray(); + var idxArr = idxList.ToArray(); + var faceMatArr = faceMatList.ToArray(); + + XatlasNative.xatlasCreate(); + try + { + int addErr = XatlasNative.xatlasAddUvMesh(uvArr, (uint)vc, idxArr, + (uint)ic, faceMatArr, (uint)fc); + if (addErr != 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E: '{lg.name}' xatlasAddUvMesh err={addErr}"); + return; + } + XatlasNative.xatlasComputeCharts(); + XatlasNative.xatlasPackCharts( + maxChartSize: 0, + padding: (uint)opts.interDomainPaddingPx, + texelsPerUnit: texelsPerUnit, // fixed → identical texel density + resolution: (uint)opts.atlasResolutionPx, + bilinear: 1, + blockAlign: 0, + bruteForce: 1, + rotateCharts: 0, + rotateChartsToAxis: 0); + + if (XatlasNative.xatlasGetMeshCount() <= 0) return; + int outVc = XatlasNative.xatlasGetOutputVertexCount(0); + int outIc = XatlasNative.xatlasGetOutputIndexCount(0); + if (outVc <= 0 || outIc <= 0) return; + + var xref = new uint[outVc]; + var outUvFlat = new float[outVc * 2]; + var chartIdx = new uint[outVc]; + XatlasNative.xatlasGetOutputVertexData(0, xref, outUvFlat, chartIdx, outVc); + var outIndsU = new uint[outIc]; + XatlasNative.xatlasGetOutputIndices(0, outIndsU, outIc); + + // Read placed UV ([0,1]) and, per group, fit the affine + // inU→atlasU and inV→atlasV by least squares over the canonical + // chart's output verts (exact: xatlas applied a uniform scale + + // translation, rotateCharts:0 so no rotation; LSQ is robust to + // any axis flip). xref[i] maps an output vert back to our input + // vert, whose group + (inU,inV) we recorded. Also keep the bbox + // rect for the diagnostic. + int nG = r.groups.Length; + var domUv = new Vector2[outVc]; + var rectMin = new Vector2[nG]; + var rectMax = new Vector2[nG]; + // LSQ accumulators per group, per axis: n, Σin, Σout, Σin², Σin·out + var aN = new double[nG]; + var aSi = new double[nG]; var aSoU = new double[nG]; + var aSii = new double[nG]; var aSioU = new double[nG]; + var aSiV = new double[nG]; var aSoV = new double[nG]; + var aSiiV = new double[nG]; var aSioV = new double[nG]; + for (int g = 0; g < nG; g++) + { + rectMin[g] = new Vector2(float.PositiveInfinity, float.PositiveInfinity); + rectMax[g] = new Vector2(float.NegativeInfinity, float.NegativeInfinity); + } + for (int i = 0; i < outVc; i++) + { + var uv = new Vector2(outUvFlat[i * 2], outUvFlat[i * 2 + 1]); + domUv[i] = uv; + int myVert = (int)xref[i]; + if (myVert < 0 || myVert >= inputVertGroup.Count) continue; + int gid = inputVertGroup[myVert]; + if (gid < 0 || gid >= nG) continue; + double inU = inputVertU[myVert], inV = inputVertV[myVert]; + aN[gid] += 1.0; + aSi[gid] += inU; aSoU[gid] += uv.x; + aSii[gid] += inU * inU; aSioU[gid] += inU * uv.x; + aSiV[gid] += inV; aSoV[gid] += uv.y; + aSiiV[gid]+= inV * inV; aSioV[gid] += inV * uv.y; + if (uv.x < rectMin[gid].x) rectMin[gid].x = uv.x; + if (uv.y < rectMin[gid].y) rectMin[gid].y = uv.y; + if (uv.x > rectMax[gid].x) rectMax[gid].x = uv.x; + if (uv.y > rectMax[gid].y) rectMax[gid].y = uv.y; + } + + var rects = new Rect[nG]; + var placements = new DomainPlacement[nG]; + int packedGroups = 0; + for (int g = 0; g < nG; g++) + { + if (rectMax[g].x < rectMin[g].x) + { + rects[g] = new Rect(0f, 0f, 0f, 0f); // group contributed nothing + continue; + } + rects[g] = new Rect(rectMin[g].x, rectMin[g].y, + rectMax[g].x - rectMin[g].x, rectMax[g].y - rectMin[g].y); + + double n = aN[g]; + double denU = n * aSii[g] - aSi[g] * aSi[g]; + double denV = n * aSiiV[g] - aSiV[g] * aSiV[g]; + bool okU = System.Math.Abs(denU) > 1e-12; + bool okV = System.Math.Abs(denV) > 1e-12; + if (n >= 2.0 && (okU || okV)) + { + double suD = okU ? (n * aSioU[g] - aSi[g] * aSoU[g]) / denU : 0.0; + double svD = okV ? (n * aSioV[g] - aSiV[g] * aSoV[g]) / denV : 0.0; + // A zero-variance axis (perfectly straight axis-aligned + // strip in UV0) leaves that axis's LSQ slope + // unconstrained — NOT a reason to drop the whole group: + // every member shell on every LOD would silently lose + // its placement. xatlas applied a uniform scale + // (rotateCharts:0, no flip), so borrow the resolved + // axis's magnitude; finer members DO vary along the + // degenerate axis and land at the right density. + if (!okU) suD = System.Math.Abs(svD); + if (!okV) svD = System.Math.Abs(suD); + placements[g] = new DomainPlacement + { + uvc = pendingUvc[g], + scale = pendingScale[g], + su = (float)suD, + ou = (float)((aSoU[g] - suD * aSi[g]) / n), + sv = (float)svD, + ov = (float)((aSoV[g] - svD * aSiV[g]) / n), + valid = true, + }; + } + else if (n >= 1.0 && texelsPerUnit > 0f) + { + // Both axes degenerate — the canonical chart collapsed + // to a point in UV0. Fall back to the designed mapping: + // the pack ran at a fixed texelsPerUnit, so the scale is + // texelsPerUnit/resolution; anchor at the mean placed UV + // so members at least land inside their packed slot. + double s = texelsPerUnit / (double)opts.atlasResolutionPx; + placements[g] = new DomainPlacement + { + uvc = pendingUvc[g], + scale = pendingScale[g], + su = (float)s, + ou = (float)((aSoU[g] - s * aSi[g]) / n), + sv = (float)s, + ov = (float)((aSoV[g] - s * aSiV[g]) / n), + valid = true, + }; + } + packedGroups++; + } + + var domTris = new int[outIc]; + for (int i = 0; i < outIc; i++) domTris[i] = (int)outIndsU[i]; + + r.domainAtlasUv = domUv; + r.domainAtlasTris = domTris; + r.domainAtlasRects = rects; + r.domainPlacements = placements; + + uint aw = XatlasNative.xatlasGetAtlasWidth(); + uint ah = XatlasNative.xatlasGetAtlasHeight(); + uint charts = XatlasNative.xatlasGetChartCount(); + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E: '{lg.name}' packed {packedGroups}/{r.groups.Length} " + + $"domain charts (xatlas charts={charts}) into {aw}×{ah} atlas " + + $"(canonical faces={fc}, outVerts={outVc}, " + + $"texels/unit={texelsPerUnit:F1}, canonArea={totalCanonArea:F2})"); + } + finally { XatlasNative.xatlasDestroy(); } + } + + // ─── Stage E (slice E2): cascade — align every LOD to its domain ── + + /// Stage E, slice 2 — the cascade payoff. Stage E1 PRESERVED + /// each group's canonical shell UV0 (recentre + uniform scale) and + /// fitted r.domainPlacements[groupId]: uvc, scale, and the affine + /// (scaled-UV0 → atlas UV). Here EVERY shell on EVERY LOD reuses that + /// SAME placement on its OWN authored UV0: + /// in = (uv0 − uvc)·scale; uv = (su·in.x+ou, sv·in.y+ov). Members + /// of a lighting domain share UV0 layout across LODs, so the same UV0 + /// coord maps to the same atlas texel on every LOD → a matched finer + /// shell lands in the SAME atlas region as its canonical, at the SAME + /// texel density, with the shell's real unwrap intact (no planar + /// re-projection, no distortion). An unmatched shell is the canonical of + /// its own fresh group and uses the placement for its repacked slot. + /// + /// Fills r.finalUv2 / r.finalTris / + /// r.finalSourceVertexIdx per LOD (output verts are one per face + /// corner — no dedup; Stage F copies attributes from the source vertex + /// each one points at). Rendered by lod{N}_final_uv2.png. + static void BuildCascadedUv2(LODGroup lg, Options opts, float meshDiag, Result r) + { + if (r.groups == null || r.groups.Length == 0) return; + if (r.perLodShells == null || r.perLodShellToGroup == null) return; + if (r.domainPlacements == null) return; + + var lods = lg.GetLODs(); + int lodCount = lods.Length; + r.finalUv2 = new Vector2[lodCount][]; + r.finalTris = new int[lodCount][]; + r.finalSourceVertexIdx = new int[lodCount][]; + r.finalFaceShell = new int[lodCount][]; + r.finalFaceGroup = new int[lodCount][]; + r.finalUnplacedFaces = new int[lodCount]; + + for (int li = 0; li < lodCount; li++) + { + var shells = r.perLodShells[li]; + var shellToGroup = r.perLodShellToGroup[li]; + if (shells == null || shellToGroup == null) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + + BuildFaceData(mesh, rs[0].transform, meshDiag, + out Vector3[] _, out int[] rt, out _, out _); + var uv0 = mesh.uv; + if (uv0 == null || uv0.Length == 0) continue; + + var uvOut = new List(rt.Length); + var triOut = new List(rt.Length); + var srcOut = new List(rt.Length); + var shellOut = new List(rt.Length / 3); // per emitted face + var groupOut = new List(rt.Length / 3); + int unplacedShells = 0, unplacedFaces = 0; + + for (int s = 0; s < shells.Length; s++) + { + int gid = (s < shellToGroup.Length) ? shellToGroup[s] : -1; + bool placed = gid >= 0 && gid < r.groups.Length + && gid < r.domainPlacements.Length + && r.domainPlacements[gid].valid; + // A shell whose group never got a placement (Stage E1 + // produced nothing for it) must STILL be emitted — + // skipping it would delete real geometry from the final + // meshes. Park its uv2 at (0,0): a bad bake on those + // faces, never a hole in the mesh. + var pl = placed ? r.domainPlacements[gid] : default; + if (!placed) unplacedShells++; + + // PRESERVE the shell: take its own authored UV0, recentre + + // uniform-scale into the SAME frame the canonical was fitted + // in (canonical's uvc + scale), then apply the group affine. + // Members of a domain share UV0 layout across LODs, so the + // same UV0 coord maps to the same atlas texel on every LOD — + // one bake valid everywhere, at uniform texel density, with + // the shell's real unwrap intact (no planar re-projection). + var sh = shells[s]; + if (sh.faceIndices == null) continue; + foreach (int f in sh.faceIndices) + { + if (f < 0 || f * 3 + 2 >= rt.Length) continue; + int a = rt[f * 3], b = rt[f * 3 + 1], c = rt[f * 3 + 2]; + if (a >= uv0.Length || b >= uv0.Length || c >= uv0.Length) continue; + if (!placed) unplacedFaces++; + for (int k = 0; k < 3; k++) + { + int vi = rt[f * 3 + k]; + Vector2 uvv = Vector2.zero; + if (placed) + { + float inU = (uv0[vi].x - pl.uvc.x) * pl.scale; + float inV = (uv0[vi].y - pl.uvc.y) * pl.scale; + uvv = new Vector2(pl.su * inU + pl.ou, + pl.sv * inV + pl.ov); + } + triOut.Add(uvOut.Count); + uvOut.Add(uvv); + srcOut.Add(vi); + } + shellOut.Add(s); + groupOut.Add(gid); + } + } + + r.finalUv2[li] = uvOut.ToArray(); + r.finalTris[li] = triOut.ToArray(); + r.finalSourceVertexIdx[li] = srcOut.ToArray(); + r.finalFaceShell[li] = shellOut.ToArray(); + r.finalFaceGroup[li] = groupOut.ToArray(); + r.finalUnplacedFaces[li] = unplacedFaces; + + if (unplacedFaces > 0) + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E2: '{lg.name}' LOD{li} — {unplacedShells} shells " + + $"({unplacedFaces} faces) had no valid domain placement; emitted at " + + "uv2 (0,0) so the final mesh keeps its geometry. Check Stage E1 warnings."); + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E2: '{lg.name}' LOD{li} cascaded uv2 " + + $"verts={uvOut.Count} tris={triOut.Count / 3} shells={shells.Length}"); + } + } + + // ─── Stage E (slice E3): objective atlas quality metrics ───── + + /// True when two emitted faces share a source vertex or a + /// near-coincident atlas-UV vertex — i.e. they're seam/fan-adjacent in + /// the same island, so a tied texel between them is rasterisation + /// noise, not a UV overlap. Mirror-reused islands (the defect this + /// filter must NOT hide) live on different shells and never reach this + /// check. Known blind spot: a single triangle folded exactly over its + /// edge-neighbour passes as adjacent; folds deeper than one triangle + /// still get caught via their non-adjacent pairs. + static bool FacesTouch(Vector2[] uv, int[] tris, int[] srcIdx, + int fa, int fb, float weldTol2) + { + for (int i = 0; i < 3; i++) + { + int va = tris[fa * 3 + i]; + for (int j = 0; j < 3; j++) + { + int vb = tris[fb * 3 + j]; + if (srcIdx != null && srcIdx[va] == srcIdx[vb]) return true; + float du = uv[va].x - uv[vb].x; + float dv = uv[va].y - uv[vb].y; + if (du * du + dv * dv <= weldTol2) return true; + } + } + return false; + } + + /// Stage E, slice 3 — measure the final per-LOD UV2 on an + /// atlas-resolution texel-centre raster so the pipeline has an + /// OBJECTIVE defect scalar instead of eyeballed PNGs. Per LOD: + /// • overlapTexels / overlapShellPairs — texels claimed by 2+ + /// triangles that are neither the same face nor same-shell + /// seam-adjacent. Cross-shell conflicts always count (UV0 + /// mirror-reuse between shells IS the defect; islands that merely + /// touch contribute ~1px lines vs full-area genuine overlaps). + /// • invertedFaces / degenUvFaces / oobVerts — winding flips, + /// zero-area UV, verts outside [0,1]. + /// • tpu* — area-weighted texels-per-world-unit spread; preserve-UV0 + /// + fixed texelsPerUnit should keep this near 1×. + /// • xLodContainedPct / misalignedGroups — cross-LOD check: texels + /// of groups whose canonical lives on another LOD must land inside + /// that canonical's footprint (3×3 dilated). Low containment = + /// the asset violates the shared-UV0-layout assumption → the bake + /// would NOT be valid across LODs. + /// Pure readback — no mesh/UV mutation. Also fills the + /// lod{N}_overlap.png raster buffers. + static void ComputeStageEMetrics(LODGroup lg, Options opts, Result r) + { + if (r.finalUv2 == null || r.finalTris == null) return; + if (r.groups == null || r.finalFaceShell == null || r.finalFaceGroup == null) return; + + int res = Mathf.Clamp(opts.atlasResolutionPx, 64, 2048); + var lods = lg.GetLODs(); + int lodCount = Mathf.Min(lods.Length, r.finalUv2.Length); + r.stageEMetricsRes = res; + r.stageEMetrics = new StageEMetrics[lodCount]; + r.stageEOverlapPx = new Color32[lodCount][]; + + var ownerFaceByLod = new int[lodCount][]; + float weldTol = 0.75f / res; + float weldTol2 = weldTol * weldTol; + float halfTexel = 0.5f / res; + + for (int li = 0; li < lodCount; li++) + { + var m = new StageEMetrics { lod = li }; + r.stageEMetrics[li] = m; + var uv = r.finalUv2[li]; + var tris = r.finalTris[li]; + var srcIdx = (r.finalSourceVertexIdx != null + && li < r.finalSourceVertexIdx.Length) + ? r.finalSourceVertexIdx[li] : null; + var fShell = (li < r.finalFaceShell.Length) ? r.finalFaceShell[li] : null; + if (uv == null || tris == null || fShell == null) continue; + + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + + // World-space verts once per LOD — density needs 3D areas. + var localVerts = mesh.vertices; + var xform = rs[0].transform; + var wv = new Vector3[localVerts.Length]; + for (int i = 0; i < localVerts.Length; i++) + wv[i] = xform.TransformPoint(localVerts[i]); + + int srcFaces = 0; + for (int sm = 0; sm < mesh.subMeshCount; sm++) + srcFaces += (int)(mesh.GetIndexCount(sm) / 3); + + int faceCount = tris.Length / 3; + m.srcFaces = srcFaces; + m.faces = faceCount; + m.unplacedFaces = (r.finalUnplacedFaces != null + && li < r.finalUnplacedFaces.Length) + ? r.finalUnplacedFaces[li] : 0; + + for (int i = 0; i < uv.Length; i++) + if (uv[i].x < -halfTexel || uv[i].x > 1f + halfTexel + || uv[i].y < -halfTexel || uv[i].y > 1f + halfTexel) + m.oobVerts++; + + var ownerFace = new int[res * res]; + for (int i = 0; i < ownerFace.Length; i++) ownerFace[i] = -1; + var overlap = new bool[res * res]; + var pairSet = new HashSet(); + + var dens = new float[faceCount]; + var densArea = new float[faceCount]; + int densCount = 0; + double sumTexArea = 0.0, sumArea3 = 0.0; + + for (int f = 0; f < faceCount; f++) + { + int i0 = tris[f * 3], i1 = tris[f * 3 + 1], i2 = tris[f * 3 + 2]; + Vector2 a = uv[i0], b = uv[i1], c = uv[i2]; + float signed = 0.5f * ((b.x - a.x) * (c.y - a.y) + - (c.x - a.x) * (b.y - a.y)); + if (signed < 0f) m.invertedFaces++; + float uvArea = Mathf.Abs(signed); + if (uvArea < 1e-12f) { m.degenUvFaces++; continue; } + + if (srcIdx != null) + { + int s0 = srcIdx[i0], s1 = srcIdx[i1], s2 = srcIdx[i2]; + if (s0 < wv.Length && s1 < wv.Length && s2 < wv.Length) + { + float area3 = 0.5f * Vector3.Cross( + wv[s1] - wv[s0], wv[s2] - wv[s0]).magnitude; + if (area3 > 1e-12f) + { + float texArea = uvArea * res * res; + dens[densCount] = texArea / area3; + densArea[densCount] = area3; + densCount++; + sumTexArea += texArea; + sumArea3 += area3; + } + } + } + + // Texel-centre ownership raster (both windings welcome — + // dividing by a negative denom flips the w signs back). + float ax = a.x * res, ay = a.y * res; + float bx = b.x * res, by = b.y * res; + float cx = c.x * res, cy = c.y * res; + int x0 = Mathf.Max(0, Mathf.FloorToInt( + Mathf.Min(ax, Mathf.Min(bx, cx)) - 0.5f)); + int x1 = Mathf.Min(res - 1, Mathf.CeilToInt( + Mathf.Max(ax, Mathf.Max(bx, cx)) - 0.5f)); + int y0 = Mathf.Max(0, Mathf.FloorToInt( + Mathf.Min(ay, Mathf.Min(by, cy)) - 0.5f)); + int y1 = Mathf.Min(res - 1, Mathf.CeilToInt( + Mathf.Max(ay, Mathf.Max(by, cy)) - 0.5f)); + if (x1 < x0 || y1 < y0) continue; + float denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy); + if (Mathf.Abs(denom) < 1e-9f) continue; + float invDenom = 1f / denom; + + for (int y = y0; y <= y1; y++) + { + float py = y + 0.5f; + int row = y * res; + for (int x = x0; x <= x1; x++) + { + float px = x + 0.5f; + float w1 = ((by - cy) * (px - cx) + + (cx - bx) * (py - cy)) * invDenom; + if (w1 < 0f || w1 > 1f) continue; + float w2 = ((cy - ay) * (px - cx) + + (ax - cx) * (py - cy)) * invDenom; + if (w2 < 0f || w1 + w2 > 1f) continue; + int t = row + x; + int of = ownerFace[t]; + if (of < 0) + { + ownerFace[t] = f; + m.coveredTexels++; + continue; + } + if (of == f) continue; + bool sameShell = fShell[of] == fShell[f]; + if (sameShell && FacesTouch(uv, tris, srcIdx, of, f, weldTol2)) + continue; // seam/fan tie — legitimate + if (!overlap[t]) + { + overlap[t] = true; + m.overlapTexels++; + } + if (!sameShell && pairSet.Count < 4096) + { + long sa = fShell[of], sb = fShell[f]; + pairSet.Add(sa < sb ? (sa << 32) | sb : (sb << 32) | sa); + } + } + } + } + + m.utilizationPct = 100f * m.coveredTexels / (res * res); + m.overlapPctOfCovered = m.coveredTexels > 0 + ? 100f * m.overlapTexels / m.coveredTexels : 0f; + m.overlapShellPairs = pairSet.Count; + + if (densCount > 0 && sumArea3 > 0.0) + { + m.tpuMean = Mathf.Sqrt((float)(sumTexArea / sumArea3)); + Array.Sort(dens, densArea, 0, densCount); + double acc = 0.0; + double lo = 0.01 * sumArea3, hi = 0.99 * sumArea3; + float p1 = dens[0], p99 = dens[densCount - 1]; + bool gotLo = false; + for (int i = 0; i < densCount; i++) + { + acc += densArea[i]; + if (!gotLo && acc >= lo) { p1 = dens[i]; gotLo = true; } + if (acc >= hi) { p99 = dens[i]; break; } + } + m.tpuP1 = Mathf.Sqrt(p1); + m.tpuP99 = Mathf.Sqrt(p99); + m.tpuSpread = m.tpuP1 > 1e-6f ? m.tpuP99 / m.tpuP1 : 0f; + } + + var pxBuf = new Color32[res * res]; + var colEmpty = new Color32(12, 12, 12, 255); + var colCov = new Color32(96, 96, 96, 255); + var colOver = new Color32(255, 48, 48, 255); + for (int t = 0; t < pxBuf.Length; t++) + pxBuf[t] = overlap[t] ? colOver + : (ownerFace[t] >= 0 ? colCov : colEmpty); + r.stageEOverlapPx[li] = pxBuf; + + ownerFaceByLod[li] = ownerFace; + r.stageEMetrics[li] = m; + } + + // Cross-LOD containment: every texel of a group whose canonical + // lives on ANOTHER LOD must fall inside that canonical LOD's + // footprint for the same group (3×3 dilated to absorb raster + // noise). This is the cascade's contract — one bake valid across + // all LODs — measured instead of assumed. + int nG = r.groups.Length; + var totByGroup = new int[nG]; + var inByGroup = new int[nG]; + for (int li = 0; li < lodCount; li++) + { + var ownerFace = ownerFaceByLod[li]; + var fGroup = (li < r.finalFaceGroup.Length) ? r.finalFaceGroup[li] : null; + if (ownerFace == null || fGroup == null) continue; + Array.Clear(totByGroup, 0, nG); + Array.Clear(inByGroup, 0, nG); + + for (int y = 0; y < res; y++) + for (int x = 0; x < res; x++) + { + int f = ownerFace[y * res + x]; + if (f < 0 || f >= fGroup.Length) continue; + int g = fGroup[f]; + if (g < 0 || g >= nG) continue; + int cl = r.groups[g].canonicalLod; + if (cl == li || cl < 0 || cl >= lodCount) continue; + var canonOwner = ownerFaceByLod[cl]; + var canonGroup = (cl < r.finalFaceGroup.Length) + ? r.finalFaceGroup[cl] : null; + if (canonOwner == null || canonGroup == null) continue; + totByGroup[g]++; + bool inside = false; + for (int dy = -1; dy <= 1 && !inside; dy++) + { + int ny = y + dy; + if (ny < 0 || ny >= res) continue; + for (int dx = -1; dx <= 1; dx++) + { + int nx = x + dx; + if (nx < 0 || nx >= res) continue; + int cf = canonOwner[ny * res + nx]; + if (cf >= 0 && cf < canonGroup.Length && canonGroup[cf] == g) + { inside = true; break; } + } + } + if (inside) inByGroup[g]++; + } + + int tot = 0, contained = 0, misaligned = 0; + for (int g = 0; g < nG; g++) + { + tot += totByGroup[g]; + contained += inByGroup[g]; + if (totByGroup[g] >= 16 && inByGroup[g] * 2 < totByGroup[g]) + misaligned++; + } + var mm = r.stageEMetrics[li]; + mm.xLodTexels = tot; + mm.xLodContainedPct = tot > 0 ? 100f * contained / tot : 100f; + mm.misalignedGroups = misaligned; + r.stageEMetrics[li] = mm; + + UvtLog.Info(UvtLog.Category.Benchmark, + $"[HierRepack] Stage E3: '{lg.name}' LOD{li} — " + + $"faces={mm.faces}/{mm.srcFaces} unplaced={mm.unplacedFaces} " + + $"inverted={mm.invertedFaces} degenUv={mm.degenUvFaces} " + + $"oobVerts={mm.oobVerts} | util={mm.utilizationPct:F1}% " + + $"overlap={mm.overlapTexels}px ({mm.overlapPctOfCovered:F2}% of covered, " + + $"{mm.overlapShellPairs} shell pairs) | tpu mean={mm.tpuMean:F1} " + + $"p1={mm.tpuP1:F1} p99={mm.tpuP99:F1} spread={mm.tpuSpread:F2}x " + + $"| xLOD={mm.xLodTexels}px contained={mm.xLodContainedPct:F1}% " + + $"misalignedGroups={mm.misalignedGroups}"); + } + } + + // ─── PR-3 Stage 6: bake the new uv2 into cloned LOD meshes ──── + + /// Pick two orthonormal vectors in the plane normal to . + /// Algorithm: take any axis not parallel to n, cross to get basisU, cross again for basisV. + /// Used by when materialising 3D shells with a local basis. + static void ComputePlaneBasis(Vector3 n, out Vector3 u, out Vector3 v) + { + Vector3 helper = Mathf.Abs(n.x) < 0.9f ? Vector3.right : Vector3.up; + u = Vector3.Cross(n, helper).normalized; + v = Vector3.Cross(n, u).normalized; + } + + /// Project every CORNER VERTEX of every face in the shell onto + /// (u,v) basis centred at ; max abs value along + /// each axis becomes the half-extent. Vertex-based (not centroid-based) + /// because the projector will project the SAME vertices into the + /// atlas rect; a centroid-based extent under-shoots long thin triangles + /// (corner verts spill outside the rect → wrap-around bleeding in the + /// baked lightmap). + static void ComputeExtents(Vector3[] worldVerts, int[] rawTris, + List faceIndices, Vector3 origin, + Vector3 u, Vector3 v, out float extU, out float extV) + { + float maxU = 0f, maxV = 0f; + foreach (int f in faceIndices) + { + for (int k = 0; k < 3; k++) + { + Vector3 d = worldVerts[rawTris[f * 3 + k]] - origin; + float pu = Mathf.Abs(Vector3.Dot(d, u)); + float pv = Mathf.Abs(Vector3.Dot(d, v)); + if (pu > maxU) maxU = pu; + if (pv > maxV) maxV = pv; + } + } + // Small floor — degenerate shells (1-2 tiny triangles) would otherwise + // have zero extent and divide-by-zero in any downstream projector. + const float kMinExtent = 1e-4f; + extU = Mathf.Max(maxU, kMinExtent); + extV = Mathf.Max(maxV, kMinExtent); + } + + /// For every fine LOD that produced a Stage-5 final UV2, + /// clone the source mesh with the seam-vertex split applied: + /// each output vertex copies positions / normals / tangents / + /// uv0..uv7 / colors from mesh.vertices[finalSourceVertexIdx[i]], + /// uv2 from finalUv2[li], triangles from finalTris[li]. + /// Bounds are recomputed; normals / tangents are NOT recalculated + /// when the source had them (we keep the authored values, just + /// duplicated). Output meshes land on r.finalMeshes[li]. + /// In-memory only — the menu-driven Apply step swaps them into + /// renderers via Undo; without Apply they're orphaned and the + /// next GC cycle reclaims them. + public static void BuildFinalMeshes(LODGroup lg, Result r) + { + if (lg == null) return; + if (r.finalUv2 == null || r.finalTris == null || r.finalSourceVertexIdx == null) + return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + r.finalMeshes = new Mesh[lodCount]; + + var uvBuf = new List(); + for (int li = 0; li < lodCount; li++) + { + if (li >= r.finalUv2.Length) break; + var newUv2 = r.finalUv2[li]; + var newTris = r.finalTris[li]; + var srcIdx = r.finalSourceVertexIdx[li]; + if (newUv2 == null || newTris == null || srcIdx == null) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var src = mf != null ? mf.sharedMesh : null; + if (src == null) continue; + + int outVc = srcIdx.Length; + var clone = new Mesh + { + name = src.name + "_hierUv2", + indexFormat = outVc >= 65000 + ? UnityEngine.Rendering.IndexFormat.UInt32 + : src.indexFormat, + }; + + // vertices — required, drives output vertex count. + var srcVerts = src.vertices; + var newVerts = new Vector3[outVc]; + for (int i = 0; i < outVc; i++) newVerts[i] = srcVerts[srcIdx[i]]; + clone.vertices = newVerts; + + // Optional attributes — only copy when the source has + // them sized to the source vertex count (skinned meshes + // with extra channels are caller's problem; we don't + // touch boneWeights / bindposes here). + var srcNormals = src.normals; + if (srcNormals != null && srcNormals.Length == srcVerts.Length) + { + var newN = new Vector3[outVc]; + for (int i = 0; i < outVc; i++) newN[i] = srcNormals[srcIdx[i]]; + clone.normals = newN; + } + + var srcTangents = src.tangents; + if (srcTangents != null && srcTangents.Length == srcVerts.Length) + { + var newT = new Vector4[outVc]; + for (int i = 0; i < outVc; i++) newT[i] = srcTangents[srcIdx[i]]; + clone.tangents = newT; + } + + var srcColors = src.colors32; + if (srcColors != null && srcColors.Length == srcVerts.Length) + { + var newC = new Color32[outVc]; + for (int i = 0; i < outVc; i++) newC[i] = srcColors[srcIdx[i]]; + clone.colors32 = newC; + } + + // UV channels 0, 2..7 (1 == uv2 is replaced below). + for (int ch = 0; ch < 8; ch++) + { + if (ch == 1) continue; + uvBuf.Clear(); + src.GetUVs(ch, uvBuf); + if (uvBuf.Count == 0 || uvBuf.Count != srcVerts.Length) continue; + var newUv = new List(outVc); + for (int i = 0; i < outVc; i++) newUv.Add(uvBuf[srcIdx[i]]); + clone.SetUVs(ch, newUv); + } + + // The new lightmap UV. + clone.uv2 = newUv2; + + // Triangles last (indexFormat / vertex count must be + // set before this call). + clone.triangles = newTris; + clone.RecalculateBounds(); + + r.finalMeshes[li] = clone; + } + } + + /// PR-3 Stage B visualisation: render the classical + /// xatlas unwrap of each non-deepest LOD as a standalone layout + /// PNG. Side-by-side with proxy_uv2_active.png the operator + /// can confirm each LOD unwraps into a clean layout with the + /// expected shell orientation; Stage D's cascade projection + /// will fold these into the per-LOD final UV. + static void WriteFineClassicalUv2Pngs(string outputDir, LODGroup lg, Result r) + { + if (r.fineClassicalUv2 == null || r.fineClassicalTris == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + for (int li = 0; li < lodCount; li++) + { + if (li >= r.fineClassicalUv2.Length) break; + var uv = r.fineClassicalUv2[li]; + var tr = r.fineClassicalTris[li]; + if (uv == null || tr == null || tr.Length < 3) continue; + string path = Path.Combine(outputDir, $"lod{li}_classical_uv2.png"); + UvPngWriter.Render(path, uv, tr); + } + } + + + /// Render each fine LOD as a 3D isometric view with faces + /// shaded by proxy-sample hit density. Heat ramp: pink (zero hits = + /// fine has no proxy support → promote candidate) → light green + /// (some hits) → saturated green (many hits = overlay candidate + /// with strong proxy backing). The eye picks out promote zones + /// (pink blobs) vs overlay zones (green) without staring at CSV + /// numbers. + static void WriteProxyHitsPngs(string outputDir, LODGroup lg, Result r) + { + if (r.lodProjections == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + for (int li = 0; li < lodCount; li++) + { + var proj = r.lodProjections[li]; + if (proj.perFaceHitCount == null) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + + var localVerts = mesh.vertices; + var tris = mesh.triangles; + int faceCount = tris.Length / 3; + if (faceCount == 0 || faceCount != proj.perFaceHitCount.Length) continue; + var xform = rs[0].transform; + + var worldVerts = new Vector3[localVerts.Length]; + Vector3 mn = xform.TransformPoint(localVerts[0]); + Vector3 mx = mn; + worldVerts[0] = mn; + for (int i = 1; i < localVerts.Length; i++) + { + var p = xform.TransformPoint(localVerts[i]); + worldVerts[i] = p; + mn = Vector3.Min(mn, p); mx = Vector3.Max(mx, p); + } + + // Saturate the ramp at the per-LOD 95th-percentile hit + // count so a single hotspot doesn't wash out the rest. + int maxHits = 0; + for (int f = 0; f < faceCount; f++) + if (proj.perFaceHitCount[f] > maxHits) maxHits = proj.perFaceHitCount[f]; + int ramp = Mathf.Max(1, maxHits); + + Vector3 isoR = new Vector3( 0.7071f, 0f, -0.7071f); + Vector3 isoU = new Vector3(-0.4082f, 0.8165f, -0.4082f); + float umin = float.MaxValue, umax = float.MinValue; + float vmin = float.MaxValue, vmax = float.MinValue; + for (int cx = 0; cx < 2; cx++) + for (int cy = 0; cy < 2; cy++) + for (int cz = 0; cz < 2; cz++) + { + Vector3 corner = new Vector3( + cx == 0 ? mn.x : mx.x, + cy == 0 ? mn.y : mx.y, + cz == 0 ? mn.z : mx.z); + float cu = Vector3.Dot(corner, isoR); + float cv = Vector3.Dot(corner, isoU); + if (cu < umin) umin = cu; if (cu > umax) umax = cu; + if (cv < vmin) vmin = cv; if (cv > vmax) vmax = cv; + } + int size = 1024; + float du = umax - umin, dv = vmax - vmin; + float scale = (size * 0.92f) / Mathf.Max(Mathf.Max(du, dv), 1e-6f); + float midU = (umin + umax) * 0.5f, midV = (vmin + vmax) * 0.5f; + float half = size * 0.5f; + + var pixels = new Color32[size * size]; + var bg = new Color32(244, 244, 248, 255); + for (int i = 0; i < pixels.Length; i++) pixels[i] = bg; + + for (int f = 0; f < faceCount; f++) + { + Color32 col = HitColor(proj.perFaceHitCount[f], ramp); + Vector2 a = IsoProject(worldVerts[tris[f * 3]], isoR, isoU, midU, midV, scale, half); + Vector2 b = IsoProject(worldVerts[tris[f * 3 + 1]], isoR, isoU, midU, midV, scale, half); + Vector2 c = IsoProject(worldVerts[tris[f * 3 + 2]], isoR, isoU, midU, midV, scale, half); + RasterizeTrianglePx(pixels, size, a, b, c, col); + } + + string path = Path.Combine(outputDir, $"lod{li}_proxy_hits.png"); + EncodePng(pixels, size, size, path); + } + } + + + /// Heatmap ramp: 0 hits → pink (no proxy support), then + /// pale-green at low counts ramping to saturated green at ramp + /// hits. Diverging palette so the operator sees overlay-friendly + /// regions (green) vs detail-not-on-proxy regions (pink) at a + /// glance, even on grayscale-blind monitors. + static Color32 HitColor(int hits, int ramp) + { + if (hits <= 0) return new Color32(255, 120, 200, 255); // promote candidate + float t = Mathf.Clamp01((float)hits / ramp); + // Pale → saturated green. + byte r = (byte)Mathf.Lerp(200f, 60f, t); + byte g = (byte)Mathf.Lerp(240f, 170f, t); + byte b = (byte)Mathf.Lerp(200f, 80f, t); + return new Color32(r, g, b, 255); + } + + /// Hash-based hue palette for diagnostic PNGs whose + /// "label" is an integer id with no semantic ordering (shellId, + /// groupId). Knuth multiplicative hash spreads adjacent ids + /// across the colour wheel, magenta sentinel for -1. + static Color32 LabelColor(int id) + { + if (id < 0) return new Color32(255, 40, 200, 255); // unassigned / degen + uint h = unchecked((uint)id * 2654435761u); + float hue = (h % 360u) / 360f; + // Slightly desaturated + medium-bright so the white wire + // outlines from UvPngWriter / RasterizeTrianglePx are still + // readable on top. + Color c = Color.HSVToRGB(hue, 0.55f, 0.85f); + return new Color32( + (byte)Mathf.Clamp(c.r * 255f, 0f, 255f), + (byte)Mathf.Clamp(c.g * 255f, 0f, 255f), + (byte)Mathf.Clamp(c.b * 255f, 0f, 255f), 255); + } + + /// Stage C visualisation: render each LOD as a 3D + /// isometric view with faces shaded by their shellId. Same + /// projector + rasterizer used by WriteProxyHitsPngs. One PNG + /// per LOD: lod{N}_shells.png. + static void WritePerLodShellsPngs(string outputDir, LODGroup lg, Result r) + { + if (r.perLodShells == null || r.perLodFaceToShell == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + for (int li = 0; li < lodCount; li++) + { + if (li >= r.perLodShells.Length) break; + var faceToShell = r.perLodFaceToShell[li]; + if (faceToShell == null) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + + var localVerts = mesh.vertices; + var tris = mesh.triangles; + int faceCount = tris.Length / 3; + if (faceCount == 0 || faceCount != faceToShell.Length) continue; + var xform = rs[0].transform; + + // World-space vertex transform + AABB for iso viewport. + var worldVerts = new Vector3[localVerts.Length]; + Vector3 mn = xform.TransformPoint(localVerts[0]); + Vector3 mx = mn; + worldVerts[0] = mn; + for (int i = 1; i < localVerts.Length; i++) + { + var p = xform.TransformPoint(localVerts[i]); + worldVerts[i] = p; + mn = Vector3.Min(mn, p); mx = Vector3.Max(mx, p); + } + + Vector3 isoR = new Vector3( 0.7071f, 0f, -0.7071f); + Vector3 isoU = new Vector3(-0.4082f, 0.8165f, -0.4082f); + float umin = float.MaxValue, umax = float.MinValue; + float vmin = float.MaxValue, vmax = float.MinValue; + for (int cx = 0; cx < 2; cx++) + for (int cy = 0; cy < 2; cy++) + for (int cz = 0; cz < 2; cz++) + { + Vector3 corner = new Vector3( + cx == 0 ? mn.x : mx.x, + cy == 0 ? mn.y : mx.y, + cz == 0 ? mn.z : mx.z); + float cu = Vector3.Dot(corner, isoR); + float cv = Vector3.Dot(corner, isoU); + if (cu < umin) umin = cu; if (cu > umax) umax = cu; + if (cv < vmin) vmin = cv; if (cv > vmax) vmax = cv; + } + int size = 1024; + float du = umax - umin, dv = vmax - vmin; + float scale = (size * 0.92f) / Mathf.Max(Mathf.Max(du, dv), 1e-6f); + float midU = (umin + umax) * 0.5f, midV = (vmin + vmax) * 0.5f; + float half = size * 0.5f; + + var pixels = new Color32[size * size]; + var bg = new Color32(244, 244, 248, 255); + for (int i = 0; i < pixels.Length; i++) pixels[i] = bg; + + for (int f = 0; f < faceCount; f++) + { + Color32 col = LabelColor(faceToShell[f]); + Vector2 a = IsoProject(worldVerts[tris[f * 3]], isoR, isoU, midU, midV, scale, half); + Vector2 b = IsoProject(worldVerts[tris[f * 3 + 1]], isoR, isoU, midU, midV, scale, half); + Vector2 c = IsoProject(worldVerts[tris[f * 3 + 2]], isoR, isoU, midU, midV, scale, half); + RasterizeTrianglePx(pixels, size, a, b, c, col); + } + + string path = Path.Combine(outputDir, $"lod{li}_shells.png"); + EncodePng(pixels, size, size, path); + } + } + + /// Stage D diagnostic: same iso-view as lod{N}_shells.png + /// but each face is coloured by its lighting-domain GROUP id + /// instead of its per-LOD shell id. Because + /// is a pure hash of the integer id, a group that spans several + /// LODs gets the SAME colour on every LOD where it appears — that's + /// the visual cue that confirms the cascade converged. Unmatched + /// finer shells get fresh colours that don't appear on the deeper + /// PNGs. Written to lod{N}_groups.png. + static void WritePerLodGroupsPngs(string outputDir, LODGroup lg, Result r, + string fileSuffix = "") + { + if (r.perLodShells == null || r.perLodFaceToShell == null + || r.perLodShellToGroup == null) return; + var lods = lg.GetLODs(); + int lodCount = lods.Length; + for (int li = 0; li < lodCount; li++) + { + if (li >= r.perLodShells.Length) break; + var faceToShell = r.perLodFaceToShell[li]; + var shellToGroup = r.perLodShellToGroup[li]; + if (faceToShell == null || shellToGroup == null) continue; + var rs = lods[li].renderers; + if (rs == null || rs.Length == 0 || rs[0] == null) continue; + var mf = rs[0].GetComponent(); + var mesh = mf != null ? mf.sharedMesh : null; + if (mesh == null) continue; + + var localVerts = mesh.vertices; + var tris = mesh.triangles; + int faceCount = tris.Length / 3; + if (faceCount == 0 || faceCount != faceToShell.Length) continue; + var xform = rs[0].transform; + + var worldVerts = new Vector3[localVerts.Length]; + Vector3 mn = xform.TransformPoint(localVerts[0]); + Vector3 mx = mn; + worldVerts[0] = mn; + for (int i = 1; i < localVerts.Length; i++) + { + var p = xform.TransformPoint(localVerts[i]); + worldVerts[i] = p; + mn = Vector3.Min(mn, p); mx = Vector3.Max(mx, p); + } + + Vector3 isoR = new Vector3( 0.7071f, 0f, -0.7071f); + Vector3 isoU = new Vector3(-0.4082f, 0.8165f, -0.4082f); + float umin = float.MaxValue, umax = float.MinValue; + float vmin = float.MaxValue, vmax = float.MinValue; + for (int cx = 0; cx < 2; cx++) + for (int cy = 0; cy < 2; cy++) + for (int cz = 0; cz < 2; cz++) + { + Vector3 corner = new Vector3( + cx == 0 ? mn.x : mx.x, + cy == 0 ? mn.y : mx.y, + cz == 0 ? mn.z : mx.z); + float cu = Vector3.Dot(corner, isoR); + float cv = Vector3.Dot(corner, isoU); + if (cu < umin) umin = cu; if (cu > umax) umax = cu; + if (cv < vmin) vmin = cv; if (cv > vmax) vmax = cv; + } + int size = 1024; + float du = umax - umin, dv = vmax - vmin; + float scale = (size * 0.92f) / Mathf.Max(Mathf.Max(du, dv), 1e-6f); + float midU = (umin + umax) * 0.5f, midV = (vmin + vmax) * 0.5f; + float half = size * 0.5f; + + var pixels = new Color32[size * size]; + var bg = new Color32(244, 244, 248, 255); + for (int i = 0; i < pixels.Length; i++) pixels[i] = bg; + + for (int f = 0; f < faceCount; f++) + { + int sh = faceToShell[f]; + int gid = (sh >= 0 && sh < shellToGroup.Length) ? shellToGroup[sh] : -1; + Color32 col = LabelColor(gid); + Vector2 a = IsoProject(worldVerts[tris[f * 3]], isoR, isoU, midU, midV, scale, half); + Vector2 b = IsoProject(worldVerts[tris[f * 3 + 1]], isoR, isoU, midU, midV, scale, half); + Vector2 c = IsoProject(worldVerts[tris[f * 3 + 2]], isoR, isoU, midU, midV, scale, half); + RasterizeTrianglePx(pixels, size, a, b, c, col); + } + + string path = Path.Combine(outputDir, $"lod{li}_groups{fileSuffix}.png"); + EncodePng(pixels, size, size, path); + } + } + + /// Drive xatlasAddMesh + ComputeCharts + PackCharts on a + /// raw 3D mesh (positions + normals + indices). Returns the packed + /// per-output-vertex UV2 array and the corresponding output index + /// buffer. Output index buffer may differ from mesh.triangles — + /// xatlas can split vertices at chart seams. + static void AutoUnwrapDeepMesh(Mesh mesh, Transform xform, Options opts, + out Vector2[] outUv, out int[] outTris, out Vector3[] outWorldVerts) + { + outUv = null; outTris = null; outWorldVerts = null; + var verts = mesh.vertices; + var tris = mesh.triangles; + var normals = mesh.normals; + int vc = verts.Length; + int ic = tris.Length; + if (vc == 0 || ic == 0) return; + + var positionsFlat = new float[vc * 3]; + for (int i = 0; i < vc; i++) + { + positionsFlat[i * 3 + 0] = verts[i].x; + positionsFlat[i * 3 + 1] = verts[i].y; + positionsFlat[i * 3 + 2] = verts[i].z; + } + float[] normalsFlat = null; + if (normals != null && normals.Length == vc) + { + normalsFlat = new float[vc * 3]; + for (int i = 0; i < vc; i++) + { + normalsFlat[i * 3 + 0] = normals[i].x; + normalsFlat[i * 3 + 1] = normals[i].y; + normalsFlat[i * 3 + 2] = normals[i].z; + } + } + var indicesU = new uint[ic]; + for (int i = 0; i < ic; i++) indicesU[i] = (uint)tris[i]; + + XatlasNative.xatlasCreate(); + try + { + int addErr = XatlasNative.xatlasAddMesh(positionsFlat, normalsFlat, + (uint)vc, indicesU, (uint)ic); + if (addErr != 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[HierRepack] xatlasAddMesh err={addErr}"); + return; + } + XatlasNative.xatlasComputeCharts(); + XatlasNative.xatlasPackCharts( + maxChartSize: 0, + padding: (uint)opts.interDomainPaddingPx, + texelsPerUnit: 0f, + resolution: (uint)opts.atlasResolutionPx, + bilinear: 1, + blockAlign: 0, + bruteForce: 1, + // 0 disables the 90° chart-rotation step xatlas runs by + // default for packing density. With rotation ON the + // diagonal triangulation inside each chart flips + // between variants — operator reads it as a "rotated + // UV layout". Keeping natural orientation costs ~5% + // packing efficiency but the diagnostic stays stable. + rotateCharts: 0, + rotateChartsToAxis: 0); + + int meshCount = XatlasNative.xatlasGetMeshCount(); + if (meshCount <= 0) return; + int outVc = XatlasNative.xatlasGetOutputVertexCount(0); + int outIc = XatlasNative.xatlasGetOutputIndexCount(0); + if (outVc <= 0 || outIc <= 0) return; + + var xref = new uint[outVc]; + var uvFlat = new float[outVc * 2]; + var chartIdx = new uint[outVc]; + XatlasNative.xatlasGetOutputVertexData(0, xref, uvFlat, chartIdx, outVc); + + var outIndsU = new uint[outIc]; + XatlasNative.xatlasGetOutputIndices(0, outIndsU, outIc); + + outUv = new Vector2[outVc]; + outWorldVerts = new Vector3[outVc]; + for (int i = 0; i < outVc; i++) + { + outUv[i] = new Vector2(uvFlat[i * 2], uvFlat[i * 2 + 1]); + // xref maps each output vert back to its original input vert, + // whose local position is mesh.vertices[xref[i]]. Apply the + // deepest LOD's xform once to land in world space. + int origIdx = (int)xref[i]; + outWorldVerts[i] = xform.TransformPoint(verts[origIdx]); + } + outTris = new int[outIc]; + for (int i = 0; i < outIc; i++) outTris[i] = (int)outIndsU[i]; + } + finally { XatlasNative.xatlasDestroy(); } + } + + + static Vector2 IsoProject(Vector3 p, Vector3 isoR, Vector3 isoU, + float midU, float midV, float scale, float half) + { + float pu = Vector3.Dot(p, isoR); + float pv = Vector3.Dot(p, isoU); + return new Vector2(half + (pu - midU) * scale, + half - (pv - midV) * scale); + } + + + /// Software-rasterize a triangle given in pixel coordinates + /// with a solid color. Edge-function barycentrics with positive-only + /// inside test (top-left rule not enforced — diagnostic doesn't + /// need pixel-perfect seam handling). + static void RasterizeTrianglePx(Color32[] pix, int size, + Vector2 a, Vector2 b, Vector2 c, Color32 col) + { + int x0 = Mathf.Max(0, Mathf.FloorToInt(Mathf.Min(a.x, Mathf.Min(b.x, c.x)))); + int x1 = Mathf.Min(size - 1, Mathf.CeilToInt(Mathf.Max(a.x, Mathf.Max(b.x, c.x)))); + int y0 = Mathf.Max(0, Mathf.FloorToInt(Mathf.Min(a.y, Mathf.Min(b.y, c.y)))); + int y1 = Mathf.Min(size - 1, Mathf.CeilToInt(Mathf.Max(a.y, Mathf.Max(b.y, c.y)))); + + float denom = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y); + if (Mathf.Abs(denom) < 1e-6f) return; + float invDenom = 1f / denom; + + for (int y = y0; y <= y1; y++) + { + int row = y * size; + for (int x = x0; x <= x1; x++) + { + float w1 = ((b.y - c.y) * (x - c.x) + (c.x - b.x) * (y - c.y)) * invDenom; + float w2 = ((c.y - a.y) * (x - c.x) + (a.x - c.x) * (y - c.y)) * invDenom; + float w3 = 1f - w1 - w2; + if (w1 < 0f || w2 < 0f || w3 < 0f) continue; + pix[row + x] = col; + } + } + } + + /// Encode a pixel buffer to PNG via a transient Texture2D. + /// Caller owns the path and ensures the directory exists. + static void EncodePng(Color32[] pixels, int width, int height, string path) + { + var tex = new Texture2D(width, height, TextureFormat.RGBA32, false); + try + { + tex.SetPixels32(pixels); + tex.Apply(false, false); + File.WriteAllBytes(path, tex.EncodeToPNG()); + } + finally + { + UnityEngine.Object.DestroyImmediate(tex); + } + } + + } +} diff --git a/Editor/HierarchicalRepack.cs.meta b/Editor/HierarchicalRepack.cs.meta new file mode 100644 index 00000000..011309c4 --- /dev/null +++ b/Editor/HierarchicalRepack.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a74625f46dc14b90aeeedaabe41aa210 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/SashaRX.UnityMeshLab.Editor.asmdef b/Editor/SashaRX.UnityMeshLab.Editor.asmdef index 990278d3..66b18f28 100644 --- a/Editor/SashaRX.UnityMeshLab.Editor.asmdef +++ b/Editor/SashaRX.UnityMeshLab.Editor.asmdef @@ -15,7 +15,7 @@ { "name": "com.unity.formats.fbx", "expression": "", - "define": "LIGHTMAP_UV_TOOL_FBX_EXPORTER" + "define": "UNITY_MESH_LAB_FBX_EXPORTER" } ], "noEngineReferences": false diff --git a/Editor/Settings/MeshLabProjectSettings.cs b/Editor/Settings/MeshLabProjectSettings.cs index 7de0caab..8c645e43 100644 --- a/Editor/Settings/MeshLabProjectSettings.cs +++ b/Editor/Settings/MeshLabProjectSettings.cs @@ -15,7 +15,7 @@ class MeshLabProjectSettings : ScriptableObject public bool repackPerMesh; // ── Output ── - public string savePath = "Assets/LightmapUvTool_Output"; + public string savePath = "Assets/UnityMeshLab/Output"; // ── UV2 pipeline ── public bool sidecarMode; diff --git a/Editor/Settings/TestSuiteAsset.cs b/Editor/Settings/TestSuiteAsset.cs index 06505ebe..c4ad683a 100644 --- a/Editor/Settings/TestSuiteAsset.cs +++ b/Editor/Settings/TestSuiteAsset.cs @@ -113,6 +113,18 @@ public class SweepMatrix "grid size.")] public float[] stretchThresholdVariants = { 1.5f }; + [Tooltip("xatlas internal-oversample multiplier. Pack runs at internalRes = resolution × oversample, " + + "then UV2 is scaled back. Higher = fewer sub-pixel shells (less Stage B density amplification, " + + "see EXPERIMENTS.md a218a2b) at the cost of pack time. Defaults match RepackOptions.Default " + + "(4×). Use {1} to disable, {1,2,4} to compare. Each entry multiplies the grid size.")] + public int[] internalOversampleVariants = { 4 }; + + [Tooltip("SymSplit threshold mode variants. LegacyFixed = compiled constants for UV_NEAR / POS_FAR; " + + "Adaptive = thresholds derived per-shell from mesh / UV scale (proposal in EXPERIMENTS.md " + + "2026-04-15). Keep both entries to A/B them, or use one to pin a baseline.")] + public SymmetrySplitShells.ThresholdMode[] symSplitThresholdModeVariants = + { SymmetrySplitShells.ThresholdMode.LegacyFixed }; + [Tooltip("Call ResetPipelineState between sweep cells so each cell starts from the " + "unmodified FBX meshes. Disable only for debugging a single cell.")] public bool resetBetweenRuns = true; @@ -120,6 +132,53 @@ public class SweepMatrix [Tooltip("Parameter sweep driven by LightmapTransferTool.ExecSweep (Run Sweep button).")] public SweepMatrix sweep = new SweepMatrix(); + + /// + /// Which analysis techniques to run per case during the unified + /// benchmark. Each enabled technique writes its own artefact(s) + /// into the per-case directory bench_<ts>/<idx>_<label>/. + /// + [System.Serializable] + public class BenchTechniques + { + [Tooltip("Legacy xatlas parameter sweep (atlas res × shell pad × border pad × ARAP × stretch × oversample × symSplit). Output: legacy_sweep/* CSVs + winner/summary aggregate.")] + public bool legacyXatlasSweep = true; + + [Tooltip("Probe v3 — per-face stay/promote classification on every fine-LOD face vs the deepest LOD. Output: hier_probe.csv.")] + public bool hierarchicalProbe = true; + + [Tooltip("Hierarchical Repack dry-run — per-vertex projection classifier, atlas layout. Output: hier_repack.csv (+ atlas PNG when available).")] + public bool hierarchicalRepack = true; + + [Tooltip("Stage D cascade-threshold sweep — rebuilds each case across the " + + "cascadeMatchFrac × cascadeMinHits grid below and emits per-cell group " + + "iso-view PNGs (lod{N}_groups_mf{F}_mh{H}.png) + stage_d_sweep.csv. " + + "Comparison-only (no auto-winner): pick the knee by eyeballing the PNG " + + "grid + join/new ratios. Off by default — opt in; each cell is a full " + + "HierarchicalRepack build so the grid multiplies dry-run time.")] + public bool stageDSweep = false; + + [Tooltip("Stage D sweep: cascade matchedFrac values. A finer shell joins its parent " + + "group when the modal deeper-shell hit fraction is >= this. Lower = more " + + "merging; higher = more fresh groups. Each entry × cascadeMinHitsVariants " + + "is one cell.")] + public float[] cascadeMatchFracVariants = { 0.35f, 0.5f, 0.65f }; + + [Tooltip("Stage D sweep: minimum total sample hits before a finer shell's modal vote " + + "is trusted to join a parent group. Below = fresh group regardless of fraction.")] + public int[] cascadeMinHitsVariants = { 2, 4, 8 }; + + [Tooltip("Stage D sweep: also write a per-cell group iso-view PNG for every cell × LOD " + + "(lod{N}_groups_mf{F}_mh{H}.png). Off by default — the first sweep showed these " + + "are near-identical across cells on the major surfaces (variation is sub-pixel " + + "trim only), so they're suppressed as noise; stage_d_sweep.csv is the real " + + "output and lod{N}_groups.png (default thresholds) is the canonical visual. " + + "Enable only for a deep visual dive.")] + public bool stageDSweepEmitPngs = false; + } + + [Tooltip("Techniques to run per case during 'Run Benchmark'. All artefacts land under one bench_// directory so a single button gives a complete pipeline snapshot.")] + public BenchTechniques techniques = new BenchTechniques(); } #if UNITY_EDITOR diff --git a/Editor/SymmetrySplitShells.cs b/Editor/SymmetrySplitShells.cs index 5062d736..9e029479 100644 --- a/Editor/SymmetrySplitShells.cs +++ b/Editor/SymmetrySplitShells.cs @@ -176,7 +176,43 @@ public static int Split(Mesh mesh, List shells, out List o var shell = shells[si]; if (shell.faceIndices.Count < 4) continue; - int N = DetectFoldCount(shell, uv0C, posC, tris, verts, out int rotAxis, out Vector3 center); + int N = DetectFoldCount(shell, uv0C, posC, tris, verts, + out int rotAxis, out Vector3 center, out float voteRatio); + + // N-fold gate: only cut if the rotational evidence is strong AND + // the shell's UV genuinely self-overlaps. DetectFoldCount looks at + // 3D rotational symmetry, so a cleanly unwrapped cylinder (clean + // rectangle, no UV overlap) trips it even though xatlas can pack + // the chart as-is — and cutting it into N sawtooth sectors + // strictly degrades the unwrap. + // + // - voteRatio: fraction of sampled faces that agreed on this N. + // A real N-fold-stacked chart sees most of its faces voting; + // a weak signal (5/50 = 10% in the bug case) is almost always + // noise from a sparse coincidence. + // - UvCoverageRatio = sum(|tri UV area|) / bbox UV area: + // ~1 for a clean wrap, ~N for N stacked instances. 1.5 + // cleanly separates the cases regardless of mesh scale or + // triangle count. + // + // HasUv0Overlap (used by SplitWithParams) is NOT suitable here: + // it's a spatial-hash density check on a 0.01 UV grid and + // false-positives on any dense chart. + if (N >= 3) + { + float coverageRatio = UvCoverageRatio(shell, uv0, tris); + bool coverageStacked = coverageRatio >= 1.5f; + bool votesStrong = voteRatio >= 0.30f; + UvtLog.Info($"[SymSplit] Shell {si}: N-fold candidate N={N} " + + $"voteRatio={voteRatio:P0} coverage={coverageRatio:F2} " + + $"(stacked={coverageStacked}, strongVotes={votesStrong})"); + if (!(coverageStacked && votesStrong)) + { + UvtLog.Info($"[SymSplit] Shell {si}: gating out N-fold cut " + + $"— need both coverage>=1.5 AND voteRatio>=0.30"); + N = 1; + } + } if (N >= 3) { @@ -788,11 +824,37 @@ static List DetectBinarySplits(Mesh mesh, List shells, HashS /// Returns N (fold count), rotation axis, and center. /// Returns N=1 if no rotational symmetry is found. /// + /// + /// DetectFoldCount overload that also returns the modal-vote ratio + /// (bestVotes / sampleCount). Used by the N-fold gate in Split to + /// distinguish strong rotational evidence from a single accidental + /// hit on a sparsely-stacked mesh. + /// + static int DetectFoldCount(UvShell shell, Vector2[] uv0C, Vector3[] posC, + int[] tris, Vector3[] verts, out int rotAxis, out Vector3 center, + out float voteRatio) + { + int n = DetectFoldCountCore(shell, uv0C, posC, tris, verts, + out rotAxis, out center, out int bestVotes, out int sampleCount); + voteRatio = sampleCount > 0 ? (float)bestVotes / sampleCount : 0f; + return n; + } + static int DetectFoldCount(UvShell shell, Vector2[] uv0C, Vector3[] posC, int[] tris, Vector3[] verts, out int rotAxis, out Vector3 center) + { + return DetectFoldCountCore(shell, uv0C, posC, tris, verts, + out rotAxis, out center, out _, out _); + } + + static int DetectFoldCountCore(UvShell shell, Vector2[] uv0C, Vector3[] posC, + int[] tris, Vector3[] verts, out int rotAxis, out Vector3 center, + out int bestVotes, out int sampleCount) { rotAxis = 1; // default Y center = Vector3.zero; + bestVotes = 0; + sampleCount = 0; var faces = shell.faceIndices; if (faces.Count < 6) return 1; @@ -818,7 +880,6 @@ static int DetectFoldCount(UvShell shell, Vector2[] uv0C, Vector3[] posC, Vector3 centerSum = Vector3.zero; int centerN = 0; - int sampleCount = 0; const int maxSample = 50; foreach (int f in faces) @@ -865,7 +926,8 @@ static int DetectFoldCount(UvShell shell, Vector2[] uv0C, Vector3[] posC, if (copyCounts.Count == 0) return 1; // Find modal N (most common copy count) - int bestN = 1, bestVotes = 0; + int bestN = 1; + bestVotes = 0; foreach (var kv in copyCounts) { if (kv.Value > bestVotes) @@ -1144,6 +1206,36 @@ static int ApplyNFoldSplit(Mesh mesh, List shells, int shellIndex, // Helpers // ═══════════════════════════════════════════════════════════════ + /// + /// + /// Ratio of summed |triangle UV area| to UV bbox area for a shell. + /// A clean non-overlapping unwrap covers a fraction of its bbox so the + /// ratio is <= 1.0 (rectangle wrap ≈ 1.0, irregular shapes lower). + /// N stacked / mirrored UV instances inside one shell push the ratio + /// toward N — this is the "xatlas can't pack as-is" signal that the + /// N-fold gate uses to decide whether the rotational cut is needed. + /// Returns 0 when the shell or its bbox is degenerate. + /// + static float UvCoverageRatio(UvShell shell, Vector2[] uv0, int[] tris) + { + if (shell == null || shell.faceIndices == null || shell.faceIndices.Count == 0) + return 0f; + float bboxW = Mathf.Max(shell.boundsMax.x - shell.boundsMin.x, 1e-9f); + float bboxH = Mathf.Max(shell.boundsMax.y - shell.boundsMin.y, 1e-9f); + float bboxArea = bboxW * bboxH; + if (bboxArea < 1e-12f) return 0f; + + float sumAbsArea = 0f; + foreach (int f in shell.faceIndices) + { + int i0 = tris[f * 3], i1 = tris[f * 3 + 1], i2 = tris[f * 3 + 2]; + if (i0 >= uv0.Length || i1 >= uv0.Length || i2 >= uv0.Length) continue; + Vector2 a = uv0[i0], b = uv0[i1], c = uv0[i2]; + sumAbsArea += 0.5f * Mathf.Abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)); + } + return sumAbsArea / bboxArea; + } + /// /// Check if a shell has UV0 overlap (multiple faces sharing the same UV0 space /// but at different 3D positions). Quick check using spatial hash. diff --git a/Editor/Tools/CleanupTool.cs b/Editor/Tools/CleanupTool.cs index 28cf33de..60c23f75 100644 --- a/Editor/Tools/CleanupTool.cs +++ b/Editor/Tools/CleanupTool.cs @@ -559,8 +559,8 @@ void ScanMaterials() if (string.IsNullOrEmpty(sourceName)) continue; // Check SOURCE name (material name baked into FBX) - bool sourceIsHidden = sourceName.StartsWith("Hidden_LightmapUvTool") - || sourceName.StartsWith("Hidden/LightmapUvTool"); + bool sourceIsHidden = sourceName.StartsWith("Hidden_UnityMeshLab") + || sourceName.StartsWith("Hidden/UnityMeshLab"); bool sourceIsDefault = sourceName == "Lit" || sourceName == "Standard" || sourceName == "No Name"; @@ -588,7 +588,7 @@ void ScanMaterials() var mat = asset as Material; if (mat == null) continue; - bool isHidden = mat.name.StartsWith("Hidden_LightmapUvTool") + bool isHidden = mat.name.StartsWith("Hidden_UnityMeshLab") || mat.shader.name.StartsWith(CheckerTexturePreview.ToolShaderPrefix); bool isDefault = mat.name == "Lit" || mat.name == "No Name" || (mat.name == "Standard" && mat.shader.name == "Standard"); diff --git a/Editor/Tools/CollisionMeshTool.cs b/Editor/Tools/CollisionMeshTool.cs index 7a879461..78ca65e3 100644 --- a/Editor/Tools/CollisionMeshTool.cs +++ b/Editor/Tools/CollisionMeshTool.cs @@ -413,7 +413,7 @@ void ApplyToScene() UvtLog.Info("Collision meshes applied to scene."); } - const string AppliedMeshAssetFolder = "Assets/LightmapUvTool/GeneratedCollisionMeshes"; + const string AppliedMeshAssetFolder = "Assets/UnityMeshLab/GeneratedCollisionMeshes"; static Mesh CreateAppliedMeshCopy(Mesh source, string fallbackName) { @@ -431,15 +431,15 @@ static Mesh CreateAppliedMeshCopy(Mesh source, string fallbackName) static void EnsureAppliedMeshFolderExists() { - if (AssetDatabase.IsValidFolder("Assets/LightmapUvTool")) + if (AssetDatabase.IsValidFolder("Assets/UnityMeshLab")) { if (!AssetDatabase.IsValidFolder(AppliedMeshAssetFolder)) - AssetDatabase.CreateFolder("Assets/LightmapUvTool", "GeneratedCollisionMeshes"); + AssetDatabase.CreateFolder("Assets/UnityMeshLab", "GeneratedCollisionMeshes"); return; } - AssetDatabase.CreateFolder("Assets", "LightmapUvTool"); - AssetDatabase.CreateFolder("Assets/LightmapUvTool", "GeneratedCollisionMeshes"); + AssetDatabase.CreateFolder("Assets", "UnityMeshLab"); + AssetDatabase.CreateFolder("Assets/UnityMeshLab", "GeneratedCollisionMeshes"); } // ── Sidecar persistence ── @@ -466,7 +466,7 @@ void SaveToSidecar() // Save mesh assets to savePath (like LOD Gen does) string savePath = !string.IsNullOrEmpty(ctx.PipeSettings.savePath) ? ctx.PipeSettings.savePath - : "Assets/LightmapUvTool_Output"; + : "Assets/UnityMeshLab/Output"; if (!AssetDatabase.IsValidFolder(savePath)) { var par = Path.GetDirectoryName(savePath); diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 4e680229..547fd0ed 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -7,7 +7,7 @@ using UnityEngine; using UnityEngine.Rendering; using UnityEditor; -#if LIGHTMAP_UV_TOOL_FBX_EXPORTER +#if UNITY_MESH_LAB_FBX_EXPORTER using UnityEditor.Formats.Fbx.Exporter; #endif @@ -75,6 +75,17 @@ enum Tab { Setup, Repack, Transfer } bool stageRunRepack = true; bool stageRunTransfer = true; + // Weld stage sub-step: meshopt binary-equivalence dedup + + // GPU cache/overdraw/fetch reorder. This is NOT a UV weld — it + // removes vertices that are byte-identical in position + normal + // + uv0 (a GPU optimisation per meshoptimizer's + // generateVertexRemap, which the library docs explicitly warn + // is unsuitable for attribute-seam handling). The actual UV-aware + // seam weld is Uv0Analyzer.UvEdgeWeld. Kept ON by default to + // preserve prior behaviour, but now a separate, clearly-labelled + // toggle so the operator can run the pure UV weld alone. + bool stageWeldRunMeshopt = true; + // Per-stage outcome from the most recent ExecFullPipeline run. // Drawn as a small status icon at the right of each stage row. enum StageStatus { Idle, Running, Success, Failed, Skipped } @@ -499,9 +510,13 @@ void DrawSetupDebugSection() int bL = sm.borderPaddingPxVariants?.Length ?? 0; int arL = sm.arapIterationsVariants?.Length ?? 0; int stL = sm.stretchThresholdVariants?.Length ?? 0; + int osL = sm.internalOversampleVariants?.Length ?? 0; + int smL = sm.symSplitThresholdModeVariants?.Length ?? 0; cells = Mathf.Max(1, rL) * Mathf.Max(1, pL) * Mathf.Max(1, bL) - * Mathf.Max(1, arL) * Mathf.Max(1, stL); + * Mathf.Max(1, arL) * Mathf.Max(1, stL) + * Mathf.Max(1, osL) * Mathf.Max(1, smL); } + int caseCount = (sweepSuite != null && sweepSuite.cases != null) ? sweepSuite.cases.Count : 0; using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(sweepSuite == null || cells == 0)) @@ -509,6 +524,19 @@ void DrawSetupDebugSection() if (GUILayout.Button($"Run Sweep ({cells})", GUILayout.Height(22))) ExecSweep(sweepSuite.sweep); } + using (new EditorGUI.DisabledScope(sweepSuite == null || caseCount == 0)) + { + if (GUILayout.Button(new GUIContent($"Run Benchmark ({caseCount} cases)", + "Iterate every TestSuiteAsset.cases[]; for each model, " + + "spawn its FBX, then run every technique enabled in " + + "suite.techniques (legacyXatlasSweep, hierarchicalProbe, " + + "hierarchicalRepack, stageDSweep). All artefacts land under one directory " + + "BenchmarkReports/bench_/_ - void ExecSweep(TestSuiteAsset.SweepMatrix sm) + void ExecSweep(TestSuiteAsset.SweepMatrix sm) => ExecSweep(sm, null); + + void ExecSweep(TestSuiteAsset.SweepMatrix sm, string sweepDirOverride) { if (ctx.LodGroup == null || sm == null) return; var resArr = (sm.atlasResolutions != null && sm.atlasResolutions.Length > 0) @@ -1467,9 +1552,15 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) : new[] { ctx.ReparameterizeStretchedShells ? ctx.ArapIterations : 0 }; var stretchArr = (sm.stretchThresholdVariants != null && sm.stretchThresholdVariants.Length > 0) ? sm.stretchThresholdVariants : new[] { ctx.StretchThreshold }; + var osArr = (sm.internalOversampleVariants != null && sm.internalOversampleVariants.Length > 0) + ? sm.internalOversampleVariants : new[] { ctx.InternalOversample }; + var symModeArr = (sm.symSplitThresholdModeVariants != null && sm.symSplitThresholdModeVariants.Length > 0) + ? sm.symSplitThresholdModeVariants + : new[] { symSplitThresholdMode }; int total = resArr.Length * padArr.Length * bdrArr.Length - * arapItersArr.Length * stretchArr.Length; + * arapItersArr.Length * stretchArr.Length + * osArr.Length * symModeArr.Length; // Snapshot ctx fields we mutate — restored unconditionally below. int origRes = ctx.AtlasResolution; @@ -1478,6 +1569,8 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) bool origArapOn = ctx.ReparameterizeStretchedShells; int origArapIters = ctx.ArapIterations; float origStretchThr = ctx.StretchThreshold; + int origOversample = ctx.InternalOversample; + var origSymMode = symSplitThresholdMode; // The sweep iterates an explicit atlasResolutions array. If the // user left AutoFromTexelDensity selected, ExecRepackCore would // overwrite ctx.AtlasResolution every cell and every row would @@ -1500,12 +1593,22 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) // operator kicked off two sweeps in the same second (scripted // runs, quick UI re-clicks). Without ms the second sweep would // overwrite the first one's summary.csv / winner.json. - string sweepStamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss_fff", - System.Globalization.CultureInfo.InvariantCulture); - string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName - ?? Application.dataPath; - string sweepDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports", - $"sweep_{sweepStamp}"); + string sweepDir; + if (!string.IsNullOrEmpty(sweepDirOverride)) + { + sweepDir = sweepDirOverride; + } + else + { + // Millisecond suffix prevents back-to-back Run Sweep clicks + // from clobbering each other's summary.csv / winner.json. + string sweepStamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss-fff", + System.Globalization.CultureInfo.InvariantCulture); + string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + sweepDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports", + $"sweep_{sweepStamp}"); + } try { System.IO.Directory.CreateDirectory(sweepDir); } catch (Exception ex) { @@ -1514,6 +1617,16 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) sweepDir = null; } + // Route every per-cell BenchmarkRecorder session into the sweep + // dir so the cell CSV/JSON/PNG sit alongside summary.csv etc. + // Without this the cells write to BenchmarkReports/run_/ + // (the recorder's default) — orphaned from the aggregate. The + // override is restored in the same finally as the sweepDir + // cleanup so a thrown cell doesn't leak the redirect. + string prevRecorderOverride = BenchmarkRecorder.OutputDirectoryOverride; + if (!string.IsNullOrEmpty(sweepDir)) + BenchmarkRecorder.OutputDirectoryOverride = sweepDir; + int done = 0; bool cancelled = false; UvProgress.Begin($"Pipeline Sweep ({total} cells)", cancelable: true); @@ -1534,10 +1647,17 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) foreach (float stretchThr in stretchArr) { if (cancelled) break; + foreach (int oversample in osArr) + { + if (cancelled) break; + foreach (var symMode in symModeArr) + { + if (cancelled) break; UvProgress.Report( (float)done / Mathf.Max(1, total), $"cell {done + 1}/{total}: res={r}, shellPad={s}, borderPad={b}, " + - $"arap={arapIters}, stretch={stretchThr:F2}"); + $"arap={arapIters}, stretch={stretchThr:F2}, " + + $"oversample={oversample}, symMode={symMode}"); if (UvProgress.CancelRequested) { cancelled = true; @@ -1554,6 +1674,10 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) ctx.ReparameterizeStretchedShells = arapIters > 0; if (arapIters > 0) ctx.ArapIterations = arapIters; ctx.StretchThreshold = stretchThr; + int clampedOversample = oversample > 0 ? oversample : 1; + ctx.InternalOversample = clampedOversample; + symSplitThresholdMode = symMode; + SymmetrySplitShells.CurrentThresholdMode = symMode; if (sm.resetBetweenRuns) ResetWorkingCopies(); @@ -1561,7 +1685,14 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) // and that would break the recovery regex's _stretch(\d+p\d+)_ token. int stretchHundredths = Mathf.RoundToInt(stretchThr * 100f); string stretchTag = $"{stretchHundredths / 100}p{(stretchHundredths % 100):D2}"; - string label = $"sweep_res{r}_pad{s}_bdr{b}_arap{arapIters}_stretch{stretchTag}"; + string symTag = symMode == SymmetrySplitShells.ThresholdMode.LegacyFixed + ? "legacy" : "adaptive"; + // Embed the clamped oversample value (not the raw + // suite entry) so the cell label matches what + // actually executed — keeps recovery / winner + // parsing in sync with the run. + string label = $"sweep_res{r}_pad{s}_bdr{b}_arap{arapIters}_" + + $"stretch{stretchTag}_os{clampedOversample}_sym{symTag}"; string csvBefore = BenchmarkRecorder.LastWrittenCsvPath; try { @@ -1580,14 +1711,23 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) ? csvAfter : null; writtenCsvPaths.Add(csvPath); + // Record the SAME clamped oversample value + // used at ctx.InternalOversample for the run + // (see ≈40 lines above). Storing the raw + // suite value here would make summary/winner + // metadata disagree with the actual run + // configuration whenever a suite contains + // 0 or negative entries. cellConfigs.Add(new BenchmarkSweep.CellConfig { - atlasRes = r, - shellPad = s, - borderPad = b, - arapEnabled = arapIters > 0, - arapIterations = arapIters, - stretchThreshold = stretchThr, + atlasRes = r, + shellPad = s, + borderPad = b, + arapEnabled = arapIters > 0, + arapIterations = arapIters, + stretchThreshold = stretchThr, + internalOversample = oversample > 0 ? oversample : 1, + symSplitMode = symMode, }); done++; @@ -1623,6 +1763,8 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) UvtLog.Verbose(UvtLog.Category.Benchmark, $"[Sweep] Between-cell cleanup hiccup: {ex.Message}"); } + } + } } } } @@ -1631,6 +1773,11 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) } finally { + // Restore the recorder's output redirect first — covers the + // case where the operator cancels mid-sweep and then runs + // a single-shot pipeline action that shouldn't write into + // this sweep's dir. + BenchmarkRecorder.OutputDirectoryOverride = prevRecorderOverride; if (cancelled) UvProgress.Cancel(); else UvProgress.End(); ctx.AtlasResolution = origRes; ctx.ShellPaddingPx = origPad; @@ -1638,7 +1785,10 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) ctx.ReparameterizeStretchedShells = origArapOn; ctx.ArapIterations = origArapIters; ctx.StretchThreshold = origStretchThr; + ctx.InternalOversample = origOversample; ctx.RepackResolutionMode = origResMode; + symSplitThresholdMode = origSymMode; + SymmetrySplitShells.CurrentThresholdMode = origSymMode; UvtLog.Info(UvtLog.Category.Benchmark, $"Sweep complete: {done}/{total} cells{(cancelled ? " (cancelled)" : "")}"); @@ -1661,9 +1811,373 @@ void ExecSweep(TestSuiteAsset.SweepMatrix sm) $"[Sweep] Aggregate report failed: {ex.Message}"); } } + + // Provenance manifest + auto-archive. Failure of either is + // logged but never propagates — losing reproducibility metadata + // is bad, but it shouldn't take down a completed sweep. + if (!string.IsNullOrEmpty(sweepDir) && System.IO.Directory.Exists(sweepDir)) + { + try + { + var prov = BenchmarkSweep.ResolvePackageProvenance(); + string stamp = System.IO.Path.GetFileName(sweepDir); + if (stamp != null && stamp.StartsWith("sweep_", StringComparison.Ordinal)) + stamp = stamp.Substring("sweep_".Length); + BenchmarkSweep.WriteManifest(sweepDir, new BenchmarkSweep.SweepManifest + { + sweepDir = sweepDir, + sweepStamp = stamp ?? "", + sweepLabel = ctx.LodGroup != null ? ctx.LodGroup.name : "standalone", + packageName = prov.pkgName, + packageVersion = prov.pkgVersion, + gitSha = prov.gitSha, + gitBranch = prov.gitBranch, + gitDirty = prov.gitDirty, + unityVersion = Application.unityVersion, + platform = Application.platform.ToString(), + hostUser = System.Environment.UserName, + hostMachine = System.Environment.MachineName, + hostOs = System.Environment.OSVersion.VersionString, + processor = SystemInfo.processorType, + cellCount = writtenCsvPaths.Count, + caseCount = 1, + matrix = sm, + caseLabels = null, + }); + BenchmarkSweep.ArchiveSweep(sweepDir); + } + catch (Exception ex) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Sweep] manifest/archive step failed: {ex.Message}"); + } + } + } + } + + /// + /// Unified benchmark — iterates every + /// in the suite, instantiates its fbxAsset into a temporary scene + /// root, then runs every technique enabled in + /// : + /// • legacyXatlasSweep → existing parameter grid (atlas res × pad × + /// ARAP × stretch × oversample × symSplit), + /// aggregated into summary/winner artefacts. + /// • hierarchicalProbe → probe v3 per-face stay/promote diagnostic. + /// • hierarchicalRepack → per-vertex projection classifier + atlas + /// layout (PR-2.7). + /// • stageDSweep → cascade-threshold grid (matchFrac × minHits); + /// per-cell group PNGs + stage_d_sweep.csv. + /// All artefacts for a single case land under one directory + /// BenchmarkReports/bench_<ts>/<idx>_<label>/ + /// so comparing techniques across the same model is a directory listing + /// and cross-model joins still work via the lodGroup column. + /// + /// Existing scene state is preserved: the original ctx.LodGroup + /// is restored on exit, and every spawned root is destroyed in a + /// finally block so a thrown technique or a user cancel doesn't + /// leak GameObjects. + /// + void ExecBenchmark(TestSuiteAsset suite) + { + if (suite == null || suite.sweep == null) return; + if (suite.cases == null || suite.cases.Count == 0) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + "[Bench] Suite has no cases — nothing to do."); + return; + } + var tech = suite.techniques ?? new TestSuiteAsset.BenchTechniques(); + if (!tech.legacyXatlasSweep && !tech.hierarchicalProbe + && !tech.hierarchicalRepack && !tech.stageDSweep) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + "[Bench] All techniques disabled in suite.techniques — nothing to do."); + return; + } + + // Snapshot the operator-bound LODGroup so the multi-case loop's + // ctx.Refresh calls don't leave the editor pointing at a destroyed + // temporary instance when the loop ends or is cancelled. + var origLodGroup = ctx.LodGroup; + // AGENTS.md LODGroup-lifecycle invariant: restore fbxMesh on every + // MeshFilter and destroy temporary working meshes BEFORE ctx.Refresh + // wipes MeshEntries. Without this, if the operator had repacked / + // transferred meshes live on origLodGroup, those temporary meshes + // stay assigned in-scene and the references needed to restore the + // FBX baseline are lost; reloading origLodGroup at the end would + // then treat the temp meshes as the new baseline. + if (origLodGroup != null) ResetWorkingCopies(); + + // Human-readable run stamp with ms precision — `yyyy-MM-dd_HH-mm-ss-fff`. + // The fff suffix prevents two rapid-fire Run Benchmark clicks + // from targeting the same bench_/ directory (the per-case + // paths inside are deterministic, so collisions silently + // interleave artefacts and corrupt comparisons). UTC so two + // operators in different timezones produce comparable folder names. + string runStamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss-fff", + System.Globalization.CultureInfo.InvariantCulture); + string projectRoot = System.IO.Directory.GetParent(Application.dataPath)?.FullName + ?? Application.dataPath; + string baseDir = System.IO.Path.Combine(projectRoot, "BenchmarkReports"); + + int caseCount = suite.cases.Count; + int doneCases = 0; + bool overallCancelled = false; + string benchRunDir = System.IO.Path.Combine(baseDir, $"bench_{runStamp}"); + System.IO.Directory.CreateDirectory(benchRunDir); + UvProgress.Begin($"Benchmark ({caseCount} models)", cancelable: true); + try + { + for (int ci = 0; ci < caseCount; ci++) + { + if (UvProgress.CancelRequested) { overallCancelled = true; break; } + var tc = suite.cases[ci]; + if (tc == null || tc.fbxAsset == null) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Bench] Case {ci}: null FBX, skipping."); + continue; + } + + string fbxPath = AssetDatabase.GetAssetPath(tc.fbxAsset); + if (string.IsNullOrEmpty(fbxPath)) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}': asset has no project path, skipping."); + continue; + } + + var prefabRoot = AssetDatabase.LoadAssetAtPath(fbxPath); + if (prefabRoot == null) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}': '{fbxPath}' is not a GameObject prefab, skipping."); + continue; + } + + UvProgress.Report((float)ci / caseCount, + $"case {ci + 1}/{caseCount}: {tc.label} ({System.IO.Path.GetFileName(fbxPath)})"); + + GameObject spawned = null; + try + { + spawned = (GameObject)PrefabUtility.InstantiatePrefab(prefabRoot); + if (spawned == null) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}': InstantiatePrefab returned null, skipping."); + continue; + } + spawned.name = $"[Bench] {tc.label}"; + // DontSave so the temporary spawn doesn't mark the scene + // dirty and survive into Ctrl+S — the multi-case sweep is + // a transient operation, not an authored edit. + spawned.hideFlags = HideFlags.DontSave; + + // Resolve LODGroup: explicit path first, then first found. + LODGroup lg = null; + if (!string.IsNullOrEmpty(tc.lodGroupPath)) + { + var t = spawned.transform.Find(tc.lodGroupPath); + if (t != null) lg = t.GetComponent(); + } + if (lg == null) lg = spawned.GetComponentInChildren(true); + if (lg == null) + { + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}': no LODGroup found under '{fbxPath}', skipping."); + continue; + } + + ctx.Refresh(lg); + OnRefresh(); + + // Per-case subdirectory groups every technique's + // artefacts for this model. Each technique lives in + // its own subfolder (hier/, legacy/) so an operator + // comparing techniques for one model just opens the + // matching folder — no mixed naming conventions, + // no top-level clutter. The case index + // is prefixed so two cases that sanitise to the same + // slug (e.g. "Chair A" and "Chair/A" both collapsing + // to "Chair_A") still land in distinct directories + // instead of overwriting each other. lodGroup name is + // also recorded in every CSV row by BenchmarkRecorder, + // so pandas joins still work across cases. + string safeLabel = SanitizeForPath(string.IsNullOrEmpty(tc.label) ? lg.name : tc.label); + string caseDir = System.IO.Path.Combine(benchRunDir, + $"{ci:D2}_{safeLabel}"); + System.IO.Directory.CreateDirectory(caseDir); + + bool didAnything = false; + + // Split per-technique into subdirectories so the case + // root only contains technique-named folders — no + // mixed naming conventions, no clash between probe's + // probe.csv and legacy's summary.csv. + // caseDir/hier/ — probe.csv + repack.csv + PNGs + // caseDir/legacy/ — summary/winner/manifest/index + // + per-cell CSV/JSON/PNG/ + string hierDir = System.IO.Path.Combine(caseDir, "hier"); + string legacyDir = System.IO.Path.Combine(caseDir, "legacy"); + + // Legacy xatlas parameter sweep — drops its + // summary.csv / winner.json / per-cell artefacts into + // legacyDir. BenchmarkRecorder.WriteArtefacts hardcodes + // top-level BenchmarkReports/ for its per-cell output; + // the override redirects them into legacyDir alongside + // the aggregate. Restored in finally so a throw doesn't + // leak the redirect into unrelated tool invocations. + if (tech.legacyXatlasSweep) + { + System.IO.Directory.CreateDirectory(legacyDir); + string prevOverride = BenchmarkRecorder.OutputDirectoryOverride; + BenchmarkRecorder.OutputDirectoryOverride = legacyDir; + try { ExecSweep(suite.sweep, legacyDir); } + finally { BenchmarkRecorder.OutputDirectoryOverride = prevOverride; } + didAnything = true; + } + + // Hierarchical probe v3 — probe.csv under hier/. + if (tech.hierarchicalProbe) + { + try + { + System.IO.Directory.CreateDirectory(hierDir); + HierarchicalDiag.ProbeLodGroup(lg, hierDir); + didAnything = true; + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}' probe threw: {ex.Message}"); + } + } + + // Hierarchical repack dry-run — repack.csv + atlas.png + // + lod{N}.png under hier/. + if (tech.hierarchicalRepack) + { + try + { + System.IO.Directory.CreateDirectory(hierDir); + var hrOpts = HierarchicalRepack.Options.Default; + var hrResult = HierarchicalRepack.BuildAndWriteForCase(lg, hrOpts, hierDir); + if (!string.IsNullOrEmpty(hrResult.error)) + UvtLog.Warn(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}' repack: {hrResult.error}"); + else + didAnything = true; + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}' repack threw: {ex.Message}"); + } + } + + // Stage D cascade-threshold sweep — opt-in comparison + // grid (matchFrac × minHits) over the same case. + // Writes stage_d_sweep.csv under hier/ (the real + // output); per-cell PNGs only when stageDSweepEmitPngs + // is set (off by default — near-identical across cells, + // suppressed as noise). No auto-winner (Stage E / + // lightmap-defect scalar not built yet). Full rebuild + // per cell, so this multiplies the dry-run cost; gated + // behind its own flag. + if (tech.stageDSweep) + { + try + { + System.IO.Directory.CreateDirectory(hierDir); + HierarchicalRepack.BuildStageDSweep(lg, + HierarchicalRepack.Options.Default, + tech.cascadeMatchFracVariants, + tech.cascadeMinHitsVariants, hierDir, + tech.stageDSweepEmitPngs); + didAnything = true; + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}' Stage D sweep threw: {ex.Message}"); + } + } + + if (didAnything) doneCases++; + } + catch (Exception ex) + { + UvtLog.Error(UvtLog.Category.Benchmark, + $"[Bench] Case {ci} '{tc.label}' threw: {ex.Message}"); + } + finally + { + if (spawned != null) + { + // AGENTS.md LODGroup-lifecycle invariant: ResetWorkingCopies + // BEFORE ctx.Refresh(null). The just-finished ExecSweep + // leaves the last cell's repackedMesh/transferredMesh + // refs on MeshEntries; clearing ctx without resetting + // first drops those refs without DestroyImmediate, and + // the temp meshes (Object.Instantiate clones, not + // children of `spawned`) leak — repeated multi-case + // runs accumulate them and eventually hit editor OOM. + // Use IsChildOf instead of an equality check so the + // guard still triggers when the LODGroup sits on a + // descendant of `spawned` (common prefab layout — + // prefab root holds rendering bounds, LODGroup on + // a child geometry container). Transform.IsChildOf + // returns true for itself, so the root case is + // still covered. + if (ctx.LodGroup != null + && ctx.LodGroup.transform.IsChildOf(spawned.transform)) + { + ResetWorkingCopies(); + ctx.Refresh(null); + } + UnityEngine.Object.DestroyImmediate(spawned); + } + } + + if (UvProgress.CancelRequested) { overallCancelled = true; break; } + } + } + finally + { + if (overallCancelled) UvProgress.Cancel(); else UvProgress.End(); + // Restore the operator's original wiring. + ctx.Refresh(origLodGroup); + OnRefresh(); + string techList = string.Join("+", + new[] + { + tech.legacyXatlasSweep ? "legacy" : null, + tech.hierarchicalProbe ? "probe" : null, + tech.hierarchicalRepack ? "repack" : null, + tech.stageDSweep ? "stageDsweep" : null, + } + .Where(s => s != null)); + UvtLog.Info(UvtLog.Category.Benchmark, + $"[Bench] complete: {doneCases}/{caseCount} cases [{techList}]" + + (overallCancelled ? " (cancelled)" : "") + + $". Per-case dirs: BenchmarkReports/bench_{runStamp}/_ public void ExportVertexColorsToFbx() { -#if LIGHTMAP_UV_TOOL_FBX_EXPORTER +#if UNITY_MESH_LAB_FBX_EXPORTER if (ctx?.MeshEntries == null || ctx.MeshEntries.Count == 0) { UvtLog.Error("[FBX Export] No meshes loaded."); @@ -2942,7 +3524,7 @@ void RestoreWorkingCopiesToScene() void ExportFbx(bool overwriteSource) { -#if LIGHTMAP_UV_TOOL_FBX_EXPORTER +#if UNITY_MESH_LAB_FBX_EXPORTER if (ctx?.MeshEntries == null || ctx.MeshEntries.Count == 0) { UvtLog.Error("[FBX Export] No meshes loaded."); @@ -4115,7 +4697,7 @@ void GenerateLods() if (sourceMeshes.Count == 0) { UvtLog.Error("[GenerateLOD] No source meshes found."); return; } string savePath = ctx.PipeSettings.savePath; - if (string.IsNullOrEmpty(savePath)) savePath = "Assets/LightmapUvTool_Output"; + if (string.IsNullOrEmpty(savePath)) savePath = "Assets/UnityMeshLab/Output"; if (!AssetDatabase.IsValidFolder(savePath)) { var par = System.IO.Path.GetDirectoryName(savePath); @@ -4224,7 +4806,7 @@ void GenerateLods() void SaveAll() { string p = ctx.PipeSettings.savePath; - if (string.IsNullOrEmpty(p)) p = "Assets/LightmapUvTool_Output"; + if (string.IsNullOrEmpty(p)) p = "Assets/UnityMeshLab/Output"; if (!AssetDatabase.IsValidFolder(p)) { var par = System.IO.Path.GetDirectoryName(p); @@ -4614,7 +5196,7 @@ void EnsureSpotMaterials() { if (spotMat == null) { - var sh = Shader.Find("Hidden/LightmapUvTool/SpotProjection"); + var sh = Shader.Find("Hidden/UnityMeshLab/SpotProjection"); if (sh != null) spotMat = new Material(sh) { hideFlags = HideFlags.HideAndDontSave }; } if (shellOverlayMat == null) diff --git a/Editor/Uv0Analyzer.cs b/Editor/Uv0Analyzer.cs index 8425475d..6b1d3f20 100644 --- a/Editor/Uv0Analyzer.cs +++ b/Editor/Uv0Analyzer.cs @@ -3,6 +3,7 @@ // flipped UV triangles, overlapping shells. // Fix: welds false seams by merging duplicate vertex indices. +using System; using System.Collections.Generic; using UnityEngine; @@ -178,7 +179,7 @@ public static Mesh WeldUv0(Mesh source) int vertCount = source.vertexCount; if (uv0 == null || uv0.Length == 0) - return Object.Instantiate(source); + return UnityEngine.Object.Instantiate(source); bool hasNormals = normals != null && normals.Length == vertCount; var weldMap = BuildWeldMap(verts, uv0, normals, hasNormals); @@ -186,7 +187,7 @@ public static Mesh WeldUv0(Mesh source) if (weldMap.Count == 0) { UvtLog.Verbose($"[UV0Fix] '{source.name}': no false seams found"); - return Object.Instantiate(source); + return UnityEngine.Object.Instantiate(source); } // Remap index buffer @@ -444,7 +445,7 @@ public static Mesh SourceGuidedWeld(Mesh target, Mesh source) bool tHasNormals = tNormals != null && tNormals.Length == tVertCount; if (tUv0 == null || tUv0.Length == 0) - return Object.Instantiate(target); + return UnityEngine.Object.Instantiate(target); // ── Build source: vertex → shell ID lookup ── var sUv0 = source.uv; @@ -455,7 +456,7 @@ public static Mesh SourceGuidedWeld(Mesh target, Mesh source) if (sUv0 == null || sUv0.Length == 0) { UvtLog.Warn("[UV0Fix] Source mesh has no UV0, cannot guide weld"); - return Object.Instantiate(target); + return UnityEngine.Object.Instantiate(target); } var sourceShells = UvShellExtractor.Extract(sUv0, sTris); @@ -779,6 +780,92 @@ public static Mesh UvEdgeWeld(Mesh mesh, float uvThreshold = 0.002f) } } + // ── 1b. Detect "instance-pair" UV shells (mirror / N-fold copies) ── + // Two distinct UV shells whose UV bboxes substantially overlap + // are instance / mirror copies the artist kept separate on + // purpose (cylinder halves with mirrored unwrap, N-fold + // sectors all stacked on one UV island, etc). Welding across + // them collapses the topology into a single shell which + // SymSplit then has to re-cut via N-fold rotational — + // producing many small sawtooth sectors instead of the + // original two clean halves. Detect such pairs up front and + // block welds whose endpoints land in different members of + // an instance pair. + // + // A vertex's shellId comes from UvShellExtractor on the + // input (uv0, tris). vertShell[i] = -1 if the vertex isn't + // referenced by any triangle in the extracted shells (e.g., + // orphan vertex slot left over from earlier passes). + int[] vertShell = new int[vertCount]; + bool[,] blockedShellPair = null; + int shellCountForLog = 0; + try + { + var uvShells = UvShellExtractor.Extract(uv0, tris); + if (uvShells != null && uvShells.Count >= 2) + { + shellCountForLog = uvShells.Count; + for (int i = 0; i < vertCount; i++) vertShell[i] = -1; + for (int si = 0; si < uvShells.Count; si++) + { + var sh = uvShells[si]; + foreach (int vi in sh.vertexIndices) + if (vi >= 0 && vi < vertCount) vertShell[vi] = si; + } + blockedShellPair = new bool[uvShells.Count, uvShells.Count]; + int blocked = 0; + for (int a = 0; a < uvShells.Count; a++) + { + var ba = uvShells[a]; + float aw = Mathf.Max(ba.boundsMax.x - ba.boundsMin.x, 1e-9f); + float ah = Mathf.Max(ba.boundsMax.y - ba.boundsMin.y, 1e-9f); + float aArea = aw * ah; + for (int b = a + 1; b < uvShells.Count; b++) + { + var bb = uvShells[b]; + // UV bbox intersection area. + float ix0 = Mathf.Max(ba.boundsMin.x, bb.boundsMin.x); + float iy0 = Mathf.Max(ba.boundsMin.y, bb.boundsMin.y); + float ix1 = Mathf.Min(ba.boundsMax.x, bb.boundsMax.x); + float iy1 = Mathf.Min(ba.boundsMax.y, bb.boundsMax.y); + if (ix1 <= ix0 || iy1 <= iy0) continue; + float interArea = (ix1 - ix0) * (iy1 - iy0); + float bw = Mathf.Max(bb.boundsMax.x - bb.boundsMin.x, 1e-9f); + float bh = Mathf.Max(bb.boundsMax.y - bb.boundsMin.y, 1e-9f); + float bArea = bw * bh; + float smallerArea = Mathf.Min(aArea, bArea); + // overlap-fraction of the SMALLER shell. >= 0.5 + // means one shell sits inside the other's UV + // footprint — instance / mirror pattern, not + // a chart-edge seam. Tuning here trades + // false-blocks (legitimate near-coincident + // charts) vs false-passes (instance pairs + // welded then re-fragmented by sym-split). + const float kInstanceOverlapFrac = 0.5f; + if (smallerArea > 0f && interArea / smallerArea >= kInstanceOverlapFrac) + { + blockedShellPair[a, b] = true; + blockedShellPair[b, a] = true; + blocked++; + } + } + } + if (blocked > 0) + { + UvtLog.Verbose($"[UV0Fix] UvEdgeWeld '{mesh.name}': " + + $"{blocked} instance-pair shell pair(s) flagged " + + $"(of {uvShells.Count} shells); cross-pair welds blocked"); + } + } + } + catch (Exception ex) + { + UvtLog.Warn($"[UV0Fix] UvEdgeWeld '{mesh.name}': instance-pair " + + $"detection failed ({ex.Message}); falling back to " + + $"unconditional weld (legacy behaviour)"); + blockedShellPair = null; + } + // ── 2. Build edge adjacency ── // EdgeKey = sorted pair of position group IDs // Value = list of (vertA, vertB) where vertA is the vertex with lower group @@ -826,6 +913,22 @@ public static Mesh UvEdgeWeld(Mesh mesh, float uvThreshold = 0.002f) if (dA <= uvThreshold && dB <= uvThreshold) { + // Instance-pair guard: if endpoints land in + // two distinct shells flagged as instance + // copies (UV bboxes substantially overlap), + // skip — these were kept separate on purpose + // and welding would force SymSplit to re-cut + // them as N-fold sectors (cylinder halves → + // 11 sawtooth slivers issue). + if (blockedShellPair != null) + { + int sAa = vertShell[eA.vA], sBa = vertShell[eB.vA]; + int sAb = vertShell[eA.vB], sBb = vertShell[eB.vB]; + if (sAa >= 0 && sBa >= 0 && sAa != sBa + && blockedShellPair[sAa, sBa]) continue; + if (sAb >= 0 && sBb >= 0 && sAb != sBb + && blockedShellPair[sAb, sBb]) continue; + } if (Find(parent, eA.vA) != Find(parent, eB.vA)) { Union(parent, rank, eA.vA, eB.vA); diff --git a/Editor/Uv2DataAsset.cs b/Editor/Uv2DataAsset.cs index dab5eb79..0d4a43a2 100644 --- a/Editor/Uv2DataAsset.cs +++ b/Editor/Uv2DataAsset.cs @@ -203,7 +203,7 @@ public class ToolSettings // Pipeline public bool saveNewMeshAssets = true; - public string savePath = "Assets/LightmapUvTool_Output"; + public string savePath = "Assets/UnityMeshLab/Output"; } /// @@ -232,7 +232,7 @@ public class CollisionMeshEntry public int maxVertsPerHull; } - [CreateAssetMenu(menuName = "LightmapUvTool/UV2 Data (internal)", fileName = "uv2data")] + [CreateAssetMenu(menuName = "Mesh Lab/UV2 Data (internal)", fileName = "uv2data")] public class Uv2DataAsset : ScriptableObject, ISerializationCallbackReceiver { public const int CurrentSchemaVersion = 3; diff --git a/Editor/UvTransferPipeline.cs b/Editor/UvTransferPipeline.cs index d374d22f..747b3a33 100644 --- a/Editor/UvTransferPipeline.cs +++ b/Editor/UvTransferPipeline.cs @@ -14,7 +14,7 @@ public struct PipelineSettings public static PipelineSettings Default => new PipelineSettings { saveNewMeshAssets = true, - savePath = "Assets/LightmapUvTool_Output" + savePath = "Assets/UnityMeshLab/Output" }; } } diff --git a/Editor/UvtLog.cs b/Editor/UvtLog.cs index e45af3b8..b1d14ec4 100644 --- a/Editor/UvtLog.cs +++ b/Editor/UvtLog.cs @@ -15,23 +15,24 @@ public enum Level { Off = 0, Error = 1, Warning = 2, Info = 3, Verbose = 4 } [System.Flags] public enum Category { - General = 1 << 0, - SymSplit = 1 << 1, - Repack = 1 << 2, - Match = 1 << 3, - Dedup = 1 << 4, - Overlap = 1 << 5, - Topology = 1 << 6, - Validation = 1 << 7, - Export = 1 << 8, - Benchmark = 1 << 9, - - All = General | SymSplit | Repack | Match | Dedup | Overlap | Topology | Validation | Export | Benchmark, + General = 1 << 0, + SymSplit = 1 << 1, + Repack = 1 << 2, + Match = 1 << 3, + Dedup = 1 << 4, + Overlap = 1 << 5, + Topology = 1 << 6, + Validation = 1 << 7, + Export = 1 << 8, + Benchmark = 1 << 9, + TransferDiag = 1 << 10, + + All = General | SymSplit | Repack | Match | Dedup | Overlap | Topology | Validation | Export | Benchmark | TransferDiag, } - const string LevelPrefKey = "LightmapUvTool_LogLevel"; - const string MaskPrefKey = "LightmapUvTool_LogCategoryMask"; - const string Prefix = "[LightmapUV]"; + const string LevelPrefKey = "UnityMeshLab_LogLevel"; + const string MaskPrefKey = "UnityMeshLab_LogCategoryMask"; + const string Prefix = "[MeshLab]"; static Level? _cachedLevel; static int? _cachedMask; diff --git a/Editor/XatlasNative.cs b/Editor/XatlasNative.cs index c9fb6813..57d0f900 100644 --- a/Editor/XatlasNative.cs +++ b/Editor/XatlasNative.cs @@ -22,6 +22,17 @@ public static class XatlasNative uint[] faceMaterialData, uint faceCount); + // Raw 3D mesh input for true auto-unwrap. Pair with + // xatlasComputeCharts (xatlas builds charts from scratch via + // hard-edge detection on positions + normals) + xatlasPackCharts. + // Pass null for normals to let xatlas derive face normals. + [DllImport(DLL)] public static extern int xatlasAddMesh( + float[] positions, + float[] normals, + uint vertexCount, + uint[] indexData, + uint indexCount); + // ── Processing ── [DllImport(DLL)] public static extern void xatlasComputeCharts(); diff --git a/Editor/XatlasRepack.cs b/Editor/XatlasRepack.cs index dacba5dc..ff74d1c4 100644 --- a/Editor/XatlasRepack.cs +++ b/Editor/XatlasRepack.cs @@ -137,7 +137,11 @@ public struct RepackOptions texelsPerUnit = 0f, maxChartSize = 0, // 0 = unbounded bilinear = true, - blockAlign = false, + // Snap charts to block boundaries (4×4 by default — DXT/BC + // tile size). Stops a chart's right/bottom edge from + // straddling a compression block edge, which otherwise eats + // 1 texel of leak budget on every compressed lightmap. + blockAlign = true, blockSize = 4, // BC/ETC/DXT default; ASTC: 4/5/6/8/10/12 bruteForce = true, rotateCharts = true, diff --git a/Native.meta b/Native.meta deleted file mode 100644 index 4eee32bc..00000000 --- a/Native.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 20c11f1576b549329618b82c549efcea -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Native~/xatlas-unity-bridge.cpp b/Native~/xatlas-unity-bridge.cpp index c3089669..3bdddd43 100644 --- a/Native~/xatlas-unity-bridge.cpp +++ b/Native~/xatlas-unity-bridge.cpp @@ -60,6 +60,42 @@ EXPORT int xatlasAddUvMesh( return (int)err; } +// Raw 3D mesh input for TRUE auto-unwrap. xatlas::ComputeCharts will +// generate charts from scratch using hard-edge detection on +// positions + normals; no UV0 hint is needed. Pair with +// xatlasComputeCharts (default ChartOptions) and xatlasPackCharts. +// +// positions: float3 per vertex (xyz) +// normals: float3 per vertex (xyz) — may be null; xatlas will derive +// face normals from positions in that case +// indices: uint32 triangle index buffer +EXPORT int xatlasAddMesh( + const float* positions, + const float* normals, + uint32_t vertexCount, + const uint32_t* indexData, + uint32_t indexCount) +{ + if (!s_atlas) return -1; + + xatlas::MeshDecl decl; + memset(&decl, 0, sizeof(decl)); + decl.vertexPositionData = positions; + decl.vertexPositionStride = sizeof(float) * 3; + decl.vertexCount = vertexCount; + if (normals) + { + decl.vertexNormalData = normals; + decl.vertexNormalStride = sizeof(float) * 3; + } + decl.indexData = indexData; + decl.indexCount = indexCount; + decl.indexFormat = xatlas::IndexFormat::UInt32; + + xatlas::AddMeshError err = xatlas::AddMesh(s_atlas, decl, 1); + return (int)err; +} + // ── Processing ── EXPORT void xatlasComputeCharts() diff --git a/Plugins/macOS/libxatlas-unity.dylib b/Plugins/macOS/libxatlas-unity.dylib index a46a8960..5161bc8e 100644 Binary files a/Plugins/macOS/libxatlas-unity.dylib and b/Plugins/macOS/libxatlas-unity.dylib differ diff --git a/Plugins/x86_64/libxatlas-unity.so b/Plugins/x86_64/libxatlas-unity.so index 10f1cd1c..d549104d 100755 Binary files a/Plugins/x86_64/libxatlas-unity.so and b/Plugins/x86_64/libxatlas-unity.so differ diff --git a/Plugins/x86_64/xatlas-unity.dll b/Plugins/x86_64/xatlas-unity.dll index 340df2e8..efe34300 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..84042d95 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ This repository is licensed under **MIT**. All dependencies are MIT/BSD compatib 3. Enter: ```text -https://github.com/SashaRX/UnityLodUvLightmapTransfer.git +https://github.com/SashaRX/UnityMeshLab.git ``` ### Manual installation @@ -127,7 +127,7 @@ Clone the repository into your project's `Packages/` folder: ```bash cd YourProject/Packages -git clone https://github.com/SashaRX/UnityLodUvLightmapTransfer.git com.sasharx.lightmap-uv-tool +git clone https://github.com/SashaRX/UnityMeshLab.git com.sasharx.unitymeshlab ``` ## Usage @@ -153,13 +153,13 @@ git clone https://github.com/SashaRX/UnityLodUvLightmapTransfer.git com.sasharx. The repository includes prebuilt native libraries. To rebuild: ```bash -cmake -S Native -B build -DCMAKE_BUILD_TYPE=Release +cmake -S Native~ -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release ``` -Requirements: CMake 3.20+, C++17 compiler. Dependencies (xatlas, meshoptimizer) are fetched automatically via CMake FetchContent. V-HACD is included as a header-only file in `Native/third_party/`. +Requirements: CMake 3.20+, C++17 compiler. Dependencies (xatlas, meshoptimizer) are fetched automatically via CMake FetchContent. V-HACD is included as a header-only file in `Native~/third_party/`. -GitHub Actions CI automatically builds for Windows, Linux, and macOS on changes to `Native/`. +GitHub Actions CI automatically builds for Windows, Linux, and macOS on changes to `Native~/`. ## License diff --git a/Shaders/CheckerUV2.shader b/Shaders/CheckerUV2.shader index cd317f14..db7aa95c 100644 --- a/Shaders/CheckerUV2.shader +++ b/Shaders/CheckerUV2.shader @@ -1,4 +1,4 @@ -Shader "Hidden/LightmapUvTool/CheckerUV2" +Shader "Hidden/UnityMeshLab/CheckerUV2" { Properties { diff --git a/Shaders/SpotProjection.shader b/Shaders/SpotProjection.shader index 89d508a9..9776454d 100644 --- a/Shaders/SpotProjection.shader +++ b/Shaders/SpotProjection.shader @@ -1,4 +1,4 @@ -Shader "Hidden/LightmapUvTool/SpotProjection" +Shader "Hidden/UnityMeshLab/SpotProjection" { Properties { diff --git a/Shaders/TintedTexture.shader b/Shaders/TintedTexture.shader index c458241a..21dff584 100644 --- a/Shaders/TintedTexture.shader +++ b/Shaders/TintedTexture.shader @@ -1,4 +1,4 @@ -Shader "Hidden/LightmapUvTool/TintedTexture" +Shader "Hidden/UnityMeshLab/TintedTexture" { Properties { diff --git a/Shaders/VertexAODepth.shader b/Shaders/VertexAODepth.shader index 0e4cdc98..006af2b8 100644 --- a/Shaders/VertexAODepth.shader +++ b/Shaders/VertexAODepth.shader @@ -1,4 +1,4 @@ -Shader "Hidden/LightmapUvTool/VertexAODepth" +Shader "Hidden/UnityMeshLab/VertexAODepth" { // Renders mesh geometry outputting linear depth (0=near, 1=far) to an RFloat color target. // Depth is computed from view-space Z for platform independence (no reversed-Z dependency). diff --git a/Tools~/gen.bat b/Tools~/gen.bat index 05dadc57..31558c67 100644 --- a/Tools~/gen.bat +++ b/Tools~/gen.bat @@ -3,10 +3,10 @@ rem gen.bat — wrapper around the gallery-builder script. rem Avoids typing the .py extension in chat clients that auto-link it. rem rem Usage: -rem Tools\gen.bat "" [--gallery-id ""] +rem Tools~\gen.bat "" [--gallery-id ""] rem rem Example: -rem Tools\gen.bat "_results~/noSymSplit_2026-04-28" --gallery-id "noSymSplit_2026-04-28" +rem Tools~\gen.bat "_results~/noSymSplit_2026-04-28" --gallery-id "noSymSplit_2026-04-28" setlocal set SCRIPT=%~dp0build_gallery.py