diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md
index 099b5d54..744369c6 100644
--- a/Documentation~/EXPERIMENTS.md
+++ b/Documentation~/EXPERIMENTS.md
@@ -263,3 +263,50 @@
- `FixColliders()` → `mesh.Clear()` стирает ВСЕ атрибуты. Не стрипать если mesh shared с Renderer.
- `FixMeshStripUvs()` — vertex colors `(0,0,0,0)` может быть валидное AO (полная окклюзия). Авто-стрип colors убран.
- `SaveAndReimport()` в cleanup — добавлять `bypassPaths` чтобы постпроцессор не вмешался.
+
+---
+
+## External MeshLab (desktop) — Settings to Avoid Artifacts
+
+> Обновлено: 2026-05-02
+>
+> Применимо к FBX, прошедшим через десктопный **MeshLab** (https://www.meshlab.net/) перед импортом в Unity. Не путать с пакетом **UnityMeshLab** — это про внешний инструмент.
+>
+> Программная проверка симптомов: `MeshLabArtifactValidator` (`Editor/MeshLabArtifactValidator.cs`),
+> menu **Tools → Mesh Lab → Validators → Check Imported MeshLab Artifacts** (по выделенному GameObject/asset).
+
+### Filters — что ломает геометрию
+
+1. **Merge Close Vertices** — `Distance` ставить очень маленьким (`1e-6`) или отключать filter.
+ - На дефолтном threshold склеивает per-wedge UV-вершины в одну → **UV seams разъезжаются**, текстура «плывёт» по швам.
+ - Симптом в Unity: `mesh.vertexCount == уникальных позиций`, при том что в сцене заметны видимые швы.
+
+2. **Parameterization (UV1)** — выбирать filter с поддержкой существующих UV seams: `Trivial Per-Triangle Parameterization` или `Iso Parametrization`.
+ - Дефолтный `Parameterization: Flat Plane` ломает развёртку UV1 (весь mesh проецируется в одну плоскость).
+ - Симптом: UV1 — degenerate triangles или всё в `(0,0)`.
+
+3. **Remove Duplicate Faces / Remove Duplicate Vertices** — отключать, если есть split UVs.
+ - В 3ds Max split UVs выглядят как «дубликаты» по позиции (но имеют разные UV/normal).
+ - MeshLab их склеивает → теряются UV seams и smoothing groups.
+
+### Export FBX dialog — обязательные настройки
+
+| Опция | Значение | Зачем |
+|-------|----------|-------|
+| `WedgeAllChannels` | **ON** | Сохраняет per-wedge UV / normal (т.е. split UVs / smoothing seams). |
+| `UseExistingNormals` | **ON** | Не пересчитывает нормали (иначе теряются smoothing groups → flat shading). |
+
+### Симптомы плохих настроек в Unity (что ловит валидатор)
+
+- **No UV seams**: `vertexCount` ≈ числу уникальных позиций при наличии нескольких UV-островов → `Merge Close Vertices` или `Remove Duplicate Vertices` склеили wedge.
+- **Flat-shaded normals**: > 90% треугольников имеют 3 одинаковых vertex-нормали, совпадающих с face-normal → нормали пересчитаны без сохранения smoothing.
+- **Degenerate UV1**: > 5% треугольников UV1 с нулевой площадью — broken parameterization.
+- **UV1 all zero**: все UV1 в `(0,0)` — MeshLab стёр UV1 канал.
+- **UV1 out of unit**: > 5% UV1-вершин вне `[0..1]` — Flat Plane / Iso без нормализации.
+
+### Проверка
+
+Перед импортом FBX из MeshLab в Unity:
+1. Прогнать `Tools → Mesh Lab → Validators → Check Imported MeshLab Artifacts` на корне.
+2. Если warning — пересохранить из MeshLab с правильными настройками.
+3. Если повторяется — отключить проблемный filter и переэкспортировать.
diff --git a/Editor/MeshLabArtifactValidator.cs b/Editor/MeshLabArtifactValidator.cs
new file mode 100644
index 00000000..975498b0
--- /dev/null
+++ b/Editor/MeshLabArtifactValidator.cs
@@ -0,0 +1,301 @@
+// MeshLabArtifactValidator.cs — Detect symptoms of bad external MeshLab settings
+// in imported meshes. Flags: collapsed UV seams (Merge Close Vertices),
+// recalculated normals (UseExistingNormals OFF), broken UV1 parameterization
+// (Flat Plane / wiped UV1). See Documentation~/EXPERIMENTS.md, section
+// "External MeshLab — Settings to Avoid Artifacts".
+
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEditor;
+
+namespace SashaRX.UnityMeshLab
+{
+ public static class MeshLabArtifactValidator
+ {
+ const float FlatNormalDot = 0.9999f; // cos(0.81°) — vertex normal matches face normal
+ const float FlatShadedTriThreshold = 0.90f; // ≥90% triangles flat-shaded → suspicious
+ const float DegenerateAreaEpsilon = 1e-12f;
+ const float DegenerateUv1Threshold = 0.05f; // ≥5% degenerate UV1 tris → suspicious
+ const float Uv1OutsideUnitThreshold = 0.05f; // ≥5% UV1 verts outside [0..1] → suspicious
+ const int MinVertsForSeamCheck = 16; // skip trivial meshes
+
+ public class Report
+ {
+ public string meshName;
+ public int vertexCount;
+ public int triangleCount;
+ public int submeshCount;
+
+ // Symptom 1: Merge Close Vertices ate UV seams
+ public int uniquePositionCount;
+ public float splitVertexRatio; // (vertexCount - uniquePositionCount) / vertexCount
+ public bool seamsLikelyMerged;
+
+ // Symptom 2: UseExistingNormals OFF → RecalculateNormals without smoothing
+ public bool hasNormals;
+ public int flatShadedTriCount;
+ public float flatShadedRatio;
+ public bool normalsLikelyRecalculated;
+
+ // Symptom 3: Bad UV1 parameterization
+ public bool hasUv1;
+ public int degenerateUv1TriCount;
+ public float degenerateUv1Ratio;
+ public bool uv1AllZero;
+ public int uv1OutsideUnitCount;
+ public float uv1OutsideUnitRatio;
+
+ public bool HasIssues =>
+ seamsLikelyMerged
+ || normalsLikelyRecalculated
+ || (hasUv1 && (uv1AllZero
+ || degenerateUv1Ratio >= DegenerateUv1Threshold
+ || uv1OutsideUnitRatio >= Uv1OutsideUnitThreshold));
+
+ public string Summary =>
+ $"'{meshName}' verts={vertexCount} tris={triangleCount} sub={submeshCount} | " +
+ $"uniquePos={uniquePositionCount} splitRatio={splitVertexRatio:P1} | " +
+ (hasNormals
+ ? $"flatTri={flatShadedTriCount}/{triangleCount} ({flatShadedRatio:P1}) | "
+ : "noNormals | ") +
+ (hasUv1
+ ? $"uv1 degen={degenerateUv1TriCount} ({degenerateUv1Ratio:P1}) " +
+ $"oob={uv1OutsideUnitCount} ({uv1OutsideUnitRatio:P1}) zero={uv1AllZero}"
+ : "noUv1");
+ }
+
+ ///
+ /// Analyze a mesh for symptoms of bad external MeshLab export settings.
+ /// Pure read-only inspection — does not mutate the mesh.
+ ///
+ public static Report Validate(Mesh mesh)
+ {
+ if (mesh == null) return null;
+
+ var report = new Report
+ {
+ meshName = mesh.name,
+ vertexCount = mesh.vertexCount,
+ submeshCount = mesh.subMeshCount,
+ };
+
+ var positions = mesh.vertices;
+ var normals = mesh.normals;
+ var triangles = mesh.triangles;
+ report.triangleCount = triangles.Length / 3;
+
+ var uv1List = new List();
+ mesh.GetUVs(1, uv1List);
+ report.hasUv1 = uv1List.Count == report.vertexCount && report.vertexCount > 0;
+
+ CheckCollapsedSeams(report, positions);
+ CheckRecalculatedNormals(report, positions, normals, triangles);
+ if (report.hasUv1) CheckUv1Parameterization(report, uv1List, triangles);
+
+ return report;
+ }
+
+ ///
+ /// Validate and emit log lines via UvtLog. Info on clean, Warn on issues.
+ ///
+ public static Report ValidateAndLog(Mesh mesh, string contextLabel = null)
+ {
+ var report = Validate(mesh);
+ if (report == null) return null;
+ string ctx = string.IsNullOrEmpty(contextLabel) ? "" : $"[{contextLabel}] ";
+ if (report.HasIssues)
+ {
+ UvtLog.Warn($"[MeshLabValidator] {ctx}{report.Summary}");
+ if (report.seamsLikelyMerged)
+ UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV seams likely merged " +
+ "(MeshLab 'Merge Close Vertices' or 'Remove Duplicate Vertices').");
+ if (report.normalsLikelyRecalculated)
+ UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': normals likely recalculated " +
+ "(MeshLab FBX export 'UseExistingNormals' = OFF).");
+ if (report.hasUv1 && report.uv1AllZero)
+ UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV1 is all zero — " +
+ "MeshLab parameterization wiped or never written.");
+ if (report.hasUv1 && report.degenerateUv1Ratio >= DegenerateUv1Threshold)
+ UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV1 has " +
+ $"{report.degenerateUv1Ratio:P1} degenerate triangles — likely 'Flat Plane' parameterization.");
+ if (report.hasUv1 && report.uv1OutsideUnitRatio >= Uv1OutsideUnitThreshold)
+ UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': " +
+ $"{report.uv1OutsideUnitRatio:P1} of UV1 verts outside [0..1] — parameterization not normalized.");
+ }
+ else
+ {
+ UvtLog.Info($"[MeshLabValidator] {ctx}{report.Summary} — clean.");
+ }
+ return report;
+ }
+
+ // ── Symptom 1 ─────────────────────────────────────────────────────────
+
+ static void CheckCollapsedSeams(Report report, Vector3[] positions)
+ {
+ if (positions == null || positions.Length == 0) return;
+
+ var unique = new HashSet<(int, int, int)>();
+ for (int i = 0; i < positions.Length; i++)
+ unique.Add(QuantizePos(positions[i]));
+
+ report.uniquePositionCount = unique.Count;
+ report.splitVertexRatio = positions.Length > 0
+ ? (positions.Length - unique.Count) / (float)positions.Length
+ : 0f;
+
+ // Suspicious: no split vertices at all on a non-trivial mesh with
+ // multiple submeshes or UV1 — almost always means seams got merged.
+ bool nonTrivial = positions.Length >= MinVertsForSeamCheck;
+ bool hasSeamSignal = report.submeshCount > 1 || report.hasUv1;
+ report.seamsLikelyMerged = nonTrivial
+ && hasSeamSignal
+ && report.splitVertexRatio < 1e-4f;
+ }
+
+ // ── Symptom 2 ─────────────────────────────────────────────────────────
+
+ static void CheckRecalculatedNormals(Report report, Vector3[] positions, Vector3[] normals, int[] triangles)
+ {
+ report.hasNormals = normals != null && normals.Length == positions.Length && normals.Length > 0;
+ if (!report.hasNormals || triangles.Length < 3) return;
+
+ int triCount = triangles.Length / 3;
+ int flatCount = 0;
+ for (int t = 0; t < triCount; t++)
+ {
+ int i0 = triangles[t * 3];
+ int i1 = triangles[t * 3 + 1];
+ int i2 = triangles[t * 3 + 2];
+ if (i0 >= positions.Length || i1 >= positions.Length || i2 >= positions.Length) continue;
+
+ Vector3 faceN = Vector3.Cross(positions[i1] - positions[i0], positions[i2] - positions[i0]);
+ if (faceN.sqrMagnitude < DegenerateAreaEpsilon) continue;
+ faceN.Normalize();
+
+ Vector3 n0 = normals[i0];
+ Vector3 n1 = normals[i1];
+ Vector3 n2 = normals[i2];
+ if (n0.sqrMagnitude < 0.5f || n1.sqrMagnitude < 0.5f || n2.sqrMagnitude < 0.5f) continue;
+
+ float d0 = Vector3.Dot(n0, faceN);
+ float d1 = Vector3.Dot(n1, faceN);
+ float d2 = Vector3.Dot(n2, faceN);
+ if (d0 >= FlatNormalDot && d1 >= FlatNormalDot && d2 >= FlatNormalDot)
+ flatCount++;
+ }
+
+ report.flatShadedTriCount = flatCount;
+ report.flatShadedRatio = triCount > 0 ? flatCount / (float)triCount : 0f;
+ report.normalsLikelyRecalculated = triCount >= MinVertsForSeamCheck
+ && report.flatShadedRatio >= FlatShadedTriThreshold;
+ }
+
+ // ── Symptom 3 ─────────────────────────────────────────────────────────
+
+ static void CheckUv1Parameterization(Report report, List uv1, int[] triangles)
+ {
+ int triCount = triangles.Length / 3;
+ int degenerate = 0;
+ for (int t = 0; t < triCount; t++)
+ {
+ int i0 = triangles[t * 3];
+ int i1 = triangles[t * 3 + 1];
+ int i2 = triangles[t * 3 + 2];
+ if (i0 >= uv1.Count || i1 >= uv1.Count || i2 >= uv1.Count) continue;
+
+ Vector2 a = uv1[i1] - uv1[i0];
+ Vector2 b = uv1[i2] - uv1[i0];
+ float cross = a.x * b.y - a.y * b.x;
+ if (cross * cross < DegenerateAreaEpsilon) degenerate++;
+ }
+ report.degenerateUv1TriCount = degenerate;
+ report.degenerateUv1Ratio = triCount > 0 ? degenerate / (float)triCount : 0f;
+
+ int oob = 0;
+ bool allZero = true;
+ for (int i = 0; i < uv1.Count; i++)
+ {
+ Vector2 uv = uv1[i];
+ if (uv.sqrMagnitude > 1e-12f) allZero = false;
+ if (uv.x < 0f || uv.x > 1f || uv.y < 0f || uv.y > 1f) oob++;
+ }
+ report.uv1AllZero = allZero;
+ report.uv1OutsideUnitCount = oob;
+ report.uv1OutsideUnitRatio = uv1.Count > 0 ? oob / (float)uv1.Count : 0f;
+ }
+
+ // ── Utility ───────────────────────────────────────────────────────────
+
+ static (int, int, int) QuantizePos(Vector3 p) =>
+ (Mathf.RoundToInt(p.x * 100000f),
+ Mathf.RoundToInt(p.y * 100000f),
+ Mathf.RoundToInt(p.z * 100000f));
+
+ // ── Menu integration ──────────────────────────────────────────────────
+
+ const string MenuPath = "Tools/Mesh Lab/Validators/Check Imported MeshLab Artifacts";
+
+ [MenuItem(MenuPath, true)]
+ static bool ValidateSelectionMenuValidate() =>
+ CollectMeshes(Selection.objects).Count > 0;
+
+ [MenuItem(MenuPath, false, 200)]
+ static void ValidateSelectionMenu()
+ {
+ var meshes = CollectMeshes(Selection.objects);
+ if (meshes.Count == 0)
+ {
+ UvtLog.Warn("[MeshLabValidator] Selection contains no meshes.");
+ return;
+ }
+
+ int issueCount = 0;
+ foreach (var (mesh, label) in meshes)
+ {
+ var r = ValidateAndLog(mesh, label);
+ if (r != null && r.HasIssues) issueCount++;
+ }
+ UvtLog.Info($"[MeshLabValidator] Checked {meshes.Count} mesh(es), {issueCount} with issues.");
+ }
+
+ static List<(Mesh mesh, string label)> CollectMeshes(Object[] objects)
+ {
+ var result = new List<(Mesh, string)>();
+ var seen = new HashSet();
+ if (objects == null) return result;
+
+ foreach (var obj in objects)
+ {
+ if (obj == null) continue;
+
+ if (obj is Mesh m && m != null && seen.Add(m))
+ {
+ result.Add((m, AssetDatabase.GetAssetPath(m)));
+ continue;
+ }
+
+ if (obj is GameObject go && go != null)
+ {
+ foreach (var mf in go.GetComponentsInChildren(true))
+ if (mf != null && mf.sharedMesh != null && seen.Add(mf.sharedMesh))
+ result.Add((mf.sharedMesh, mf.gameObject.name));
+ foreach (var smr in go.GetComponentsInChildren(true))
+ if (smr != null && smr.sharedMesh != null && seen.Add(smr.sharedMesh))
+ result.Add((smr.sharedMesh, smr.gameObject.name));
+ continue;
+ }
+
+ string path = AssetDatabase.GetAssetPath(obj);
+ if (!string.IsNullOrEmpty(path))
+ {
+ foreach (var sub in AssetDatabase.LoadAllAssetsAtPath(path))
+ if (sub is Mesh subMesh && subMesh != null && seen.Add(subMesh))
+ result.Add((subMesh, $"{System.IO.Path.GetFileName(path)}::{subMesh.name}"));
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/Editor/MeshLabArtifactValidator.cs.meta b/Editor/MeshLabArtifactValidator.cs.meta
new file mode 100644
index 00000000..c40bacbe
--- /dev/null
+++ b/Editor/MeshLabArtifactValidator.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ae73dea31d7b46bc81c40f977856f7f1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs
index 7351ef56..eb582460 100644
--- a/Editor/Tools/LightmapTransferTool.cs
+++ b/Editor/Tools/LightmapTransferTool.cs
@@ -1952,7 +1952,14 @@ void ExportFbx(bool overwriteSource)
var meshRendererTemplates = new Dictionary();
foreach (var (entry, resultMesh) in entries)
{
+ string meshName = ResolveExportMeshName(entry, resultMesh);
var exportMesh = UnityEngine.Object.Instantiate(resultMesh);
+ // Without an explicit name, Object.Instantiate produces "X(Clone)"
+ // and Unity's FBX Exporter falls back to the FBX scene name
+ // ("Scene") when writing the FbxMesh node — every reimported mesh
+ // ends up named "Scene". Pin the canonical name now so the FBX
+ // node and post-reimport mesh asset stay aligned with the source.
+ exportMesh.name = meshName;
// Copy UV channels from fbxMesh first (base UVs),
// then from originalMesh (has AO and other tool modifications).
if (entry.fbxMesh != null)
@@ -1974,7 +1981,6 @@ void ExportFbx(bool overwriteSource)
if (uv2Donor != null)
MergeUvComponentFromDonor(exportMesh, uv2Donor, aoUvChannel, aoUvComponent);
}
- string meshName = ResolveExportMeshName(entry, resultMesh);
meshReplacements[meshName] = exportMesh;
if (entry.renderer != null)
meshRendererTemplates[meshName] = entry.renderer;
@@ -2030,6 +2036,9 @@ void ExportFbx(bool overwriteSource)
}
var newMf = child.AddComponent();
var exportMesh = UnityEngine.Object.Instantiate(resultMesh);
+ // See the matching note in the replace-existing branch above:
+ // empty/Clone names cause Unity FBX Exporter to write "Scene".
+ exportMesh.name = meshName;
if (entry.fbxMesh != null)
PreserveUvChannels(exportMesh, entry.fbxMesh);
if (entry.originalMesh != null && entry.originalMesh != entry.fbxMesh)