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");