diff --git a/Editor/Uv2AssetPostprocessor.cs b/Editor/Uv2AssetPostprocessor.cs index e79c45ca..89562642 100644 --- a/Editor/Uv2AssetPostprocessor.cs +++ b/Editor/Uv2AssetPostprocessor.cs @@ -12,8 +12,13 @@ namespace SashaRX.UnityMeshLab { public class Uv2AssetPostprocessor : AssetPostprocessor { + // Serialized sidecars are untrusted import inputs. Bound expensive candidate + // comparisons so coincident/quantized vertices cannot make imports quadratic. + const int MaxRemapCandidateChecks = 1000000; + // Run well after Bakery and other postprocessors (default order = 0). public override int GetPostprocessOrder() => 10000; + public override uint GetVersion() => 2; /// /// Paths to bypass during the next reimport. When ApplyUv2ToFbx needs the @@ -1091,6 +1096,7 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) for (int i = 0; i < newCount; i++) newRemap[i] = -1; var coveredOpt = new bool[optCount > 0 ? optCount : 1]; int matched = 0; + int candidateChecksRemaining = MaxRemapCandidateChecks; for (int i = 0; i < newCount; i++) { @@ -1098,7 +1104,7 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) if (!posLookup.TryGetValue(key, out var candidates)) continue; int bestOld = PickBestCandidate(candidates, i, origRemap, coveredOpt, - hasUv0 ? newUv0 : null, storedUv0); + hasUv0 ? newUv0 : null, storedUv0, ref candidateChecksRemaining); if (bestOld >= 0) { @@ -1124,6 +1130,7 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) int bestOld = -1; for (int j = 0; j < storedCount; j++) { + if (candidateChecksRemaining-- <= 0) break; float d = Vector3.SqrMagnitude(newPos[i] - storedPos[j]); if (d < bestDist) { bestDist = d; bestOld = j; } } @@ -1139,6 +1146,10 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) } } + if (candidateChecksRemaining <= 0) + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': remap rebuild comparison limit reached; " + + "remaining vertices were left unmapped."); + if (matched < newCount) UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': remap rebuild matched {matched}/{newCount} " + $"({newCount - matched} unmapped)"); @@ -1174,7 +1185,8 @@ static int[] RebuildRemapFromPositions(Mesh mesh, MeshUv2Entry entry) /// just because their opt slot is already covered. /// static int PickBestCandidate(List candidates, int newIdx, int[] origRemap, bool[] coveredOpt, - List newUv0, Vector2[] storedUv0) + List newUv0, Vector2[] storedUv0, + ref int candidateChecksRemaining) { if (candidates.Count == 1) { @@ -1189,6 +1201,7 @@ static int PickBestCandidate(List candidates, int newIdx, int[] origRemap, float bestUv0Dist = float.MaxValue; for (int k = 0; k < candidates.Count; k++) { + if (candidateChecksRemaining-- <= 0) return -1; int ci = candidates[k]; if (origRemap[ci] < 0) continue; float d = hasUv ? Vector2.SqrMagnitude(newUv0[newIdx] - storedUv0[ci]) : 0f; @@ -1209,6 +1222,7 @@ static int PickBestCandidate(List candidates, int newIdx, int[] origRemap, for (int k = 0; k < candidates.Count; k++) { + if (candidateChecksRemaining-- <= 0) return -1; int ci = candidates[k]; int opt = origRemap[ci]; @@ -1304,7 +1318,9 @@ static Vector2[] RemapUvSetIfNeeded( var result = new Vector2[count]; var used = new bool[sourceUv.Length]; var meshMatched = new bool[count]; + var candidateCursors = new Dictionary<(int, int, int), int>(); int matched = 0; + int candidateChecksRemaining = MaxRemapCandidateChecks; for (int i = 0; i < count; i++) { @@ -1329,6 +1345,7 @@ static Vector2[] RemapUvSetIfNeeded( int bestIdx = -1; foreach (int ci in candidates) { + if (candidateChecksRemaining-- <= 0) break; if (used[ci]) continue; float d = Vector2.SqrMagnitude(meshUv0[i] - entry.vertUv0[ci]); if (d < bestDist) @@ -1345,11 +1362,15 @@ static Vector2[] RemapUvSetIfNeeded( matched++; } } - else + + // Missing UV0, or a deliberately huge duplicate bucket: use the + // next unused index in this bucket. The cursor makes this linear. + if (!meshMatched[i] && (!hasUv0 || candidateChecksRemaining <= 0)) { - // No UV0 data — pick first unused candidate - foreach (int ci in candidates) + candidateCursors.TryGetValue(key, out int cursor); + while (cursor < candidates.Count) { + int ci = candidates[cursor++]; if (!used[ci]) { result[i] = sourceUv[ci]; @@ -1359,6 +1380,7 @@ static Vector2[] RemapUvSetIfNeeded( break; } } + candidateCursors[key] = cursor; } } @@ -1380,6 +1402,7 @@ static Vector2[] RemapUvSetIfNeeded( int fallbackMatched = 0; foreach (int mi in unmatchedMesh) { + if (candidateChecksRemaining <= 0) break; float bestDist = float.MaxValue; int bestIdx = -1; int bestListIdx = -1; @@ -1387,6 +1410,7 @@ static Vector2[] RemapUvSetIfNeeded( // First try: nearest UNUSED sidecar vertex within tight tolerance (1mm) for (int j = 0; j < unusedSidecar.Count; j++) { + if (candidateChecksRemaining-- <= 0) break; int si = unusedSidecar[j]; float d = Vector3.SqrMagnitude(meshPos[mi] - entry.vertPositions[si]); if (d < bestDist) @@ -1401,7 +1425,9 @@ static Vector2[] RemapUvSetIfNeeded( { result[mi] = sourceUv[bestIdx]; used[bestIdx] = true; - unusedSidecar.RemoveAt(bestListIdx); + int lastListIdx = unusedSidecar.Count - 1; + unusedSidecar[bestListIdx] = unusedSidecar[lastListIdx]; + unusedSidecar.RemoveAt(lastListIdx); matched++; fallbackMatched++; nearestFallbackCount++; @@ -1416,6 +1442,7 @@ static Vector2[] RemapUvSetIfNeeded( bestIdx = -1; for (int si = 0; si < entry.vertPositions.Length; si++) { + if (candidateChecksRemaining-- <= 0) break; float d = Vector3.SqrMagnitude(meshPos[mi] - entry.vertPositions[si]); if (d < bestDist) { bestDist = d; bestIdx = si; } } @@ -1428,6 +1455,10 @@ static Vector2[] RemapUvSetIfNeeded( } } + if (candidateChecksRemaining <= 0) + UvtLog.Warn($"[UV2 Postprocess] '{mesh.name}': {channelLabel} remap comparison limit reached; " + + "remaining vertices will keep zero."); + if (nearestFallbackCount > 0) UvtLog.Info($"[UV2 Postprocess] '{mesh.name}': {nearestFallbackCount} {channelLabel} vertices matched by nearest-unused fallback");