From 15bd9ed89b1e65024b47d41a2c578a001e43fa75 Mon Sep 17 00:00:00 2001 From: SashaRX Date: Thu, 6 Aug 2026 13:54:01 +0200 Subject: [PATCH] fix: bound spatial overlap detection work --- Documentation~/EXPERIMENTS.md | 11 ++ Editor/SpatialPartitioner.cs | 114 ++++++++++++++----- Tests/Editor/SpatialPartitionerTests.cs | 74 ++++++++++++ Tests/Editor/SpatialPartitionerTests.cs.meta | 3 + 4 files changed, 172 insertions(+), 30 deletions(-) create mode 100644 Tests/Editor/SpatialPartitionerTests.cs create mode 100644 Tests/Editor/SpatialPartitionerTests.cs.meta diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 54ed2b86..4ad69722 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -11,6 +11,17 @@ 4. Документировать результат здесь ДО мержа. 5. Если ломает — реверт. Не компенсировать другим фиксом. +## Эксперимент 2026-08-06 — Линейная фильтрация overlap-ячеек + +- **Проблема:** попарная проверка всех face в каждой ячейке сетки имела худший случай + `O(gridCells * faces²)` и позволяла патологическому UV-мешу надолго блокировать Editor. +- **Изменение:** число face, имеющих общую вершину с текущим face, вычисляется линейно через + счётчики вершин, пар и троек (формула включений-исключений). Наличие остальных face означает overlap. +- **Сохранённый контракт:** соседние face с общей вершиной игнорируются, а не смежные face в общей + ячейке по-прежнему помечаются как overlap. +- **Проверка:** добавлены EditMode-тесты для обоих случаев; сложные модели необходимо проверить + локально в Unity Editor по стандартному протоколу. + ## Что НЕ работает (уроки из 5 отклонённых PR и ~10 ревертов) - **Affine UV0→UV2 mapping** → экстраполяция за пределы шелов → overlap (PR #48, reverted) diff --git a/Editor/SpatialPartitioner.cs b/Editor/SpatialPartitioner.cs index ebaf2438..059b1a64 100644 --- a/Editor/SpatialPartitioner.cs +++ b/Editor/SpatialPartitioner.cs @@ -70,13 +70,12 @@ public static ShellPartitionResult[] PartitionShells( continue; } - // Step 1: Build face adjacency (needed for both overlap detection and flood-fill) - var faceVerts = BuildFaceVertexSets(shell.faceIndices, triangles); + // Step 1: Build face adjacency for flood-fill var adjacency = BuildFaceAdjacency(shell.faceIndices, triangles); // Step 2: Overlap detection — only flag faces sharing a grid cell // with a NON-ADJACENT face (no shared vertex) - var overlappingFaces = DetectOverlap(shell, uv0, triangles, faceVerts); + var overlappingFaces = DetectOverlap(shell, uv0, triangles); r.hasOverlap = overlappingFaces.Count > 0; if (!r.hasOverlap) @@ -218,31 +217,38 @@ public static int[] GetPartitionFaces( return faces.ToArray(); } - // ════════════════════════════════════════════════════════════ - // Per-face vertex set (for fast adjacency check in overlap detection) - // ════════════════════════════════════════════════════════════ - - static Dictionary> BuildFaceVertexSets(List faceIndices, int[] triangles) + readonly struct FaceVertexKey : System.IEquatable { - var result = new Dictionary>(faceIndices.Count); - foreach (int f in faceIndices) + public readonly int a; + public readonly int b; + public readonly int c; + + public FaceVertexKey(int v0, int v1, int v2) { - var set = new HashSet(); - set.Add(triangles[f * 3]); - set.Add(triangles[f * 3 + 1]); - set.Add(triangles[f * 3 + 2]); - result[f] = set; + if (v0 > v1) { int t = v0; v0 = v1; v1 = t; } + if (v1 > v2) { int t = v1; v1 = v2; v2 = t; } + if (v0 > v1) { int t = v0; v0 = v1; v1 = t; } + a = v0; + b = v1; + c = v2; } - return result; - } - static bool FacesShareVertex(Dictionary> faceVerts, int fA, int fB) - { - if (!faceVerts.TryGetValue(fA, out var setA)) return false; - if (!faceVerts.TryGetValue(fB, out var setB)) return false; - foreach (int v in setA) - if (setB.Contains(v)) return true; - return false; + public bool Equals(FaceVertexKey other) + { + return a == other.a && b == other.b && c == other.c; + } + + public override bool Equals(object obj) => obj is FaceVertexKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int hash = a; + hash = (hash * 397) ^ b; + return (hash * 397) ^ c; + } + } } // ════════════════════════════════════════════════════════════ @@ -250,8 +256,7 @@ static bool FacesShareVertex(Dictionary> faceVerts, int fA, in // ════════════════════════════════════════════════════════════ static HashSet DetectOverlap( - UvShell shell, Vector2[] uv0, int[] triangles, - Dictionary> faceVerts) + UvShell shell, Vector2[] uv0, int[] triangles) { var overlapping = new HashSet(); @@ -294,27 +299,76 @@ static HashSet DetectOverlap( } } + // Count incidences instead of comparing every pair in a cell. For a + // face, inclusion-exclusion gives the number of cell faces sharing + // at least one of its vertices. Any remaining face is non-adjacent. + // This keeps detection linear in face-cell memberships even when a + // crafted mesh makes every face cover every grid cell. + var vertexCounts = new Dictionary(); + var pairCounts = new Dictionary(); + var tripleCounts = new Dictionary(); + foreach (var kv in cellFaces) { var list = kv.Value; if (list.Count < 2) continue; + vertexCounts.Clear(); + pairCounts.Clear(); + tripleCounts.Clear(); + + for (int i = 0; i < list.Count; i++) + { + int f = list[i]; + int i0 = triangles[f * 3], i1 = triangles[f * 3 + 1], i2 = triangles[f * 3 + 2]; + IncrementCount(vertexCounts, i0); + if (i1 != i0) IncrementCount(vertexCounts, i1); + if (i2 != i0 && i2 != i1) IncrementCount(vertexCounts, i2); + + if (i0 != i1) IncrementCount(pairCounts, VertexPairKey(i0, i1)); + if (i0 != i2) IncrementCount(pairCounts, VertexPairKey(i0, i2)); + if (i1 != i2) IncrementCount(pairCounts, VertexPairKey(i1, i2)); + if (i0 != i1 && i0 != i2 && i1 != i2) + IncrementCount(tripleCounts, new FaceVertexKey(i0, i1, i2)); + } + for (int i = 0; i < list.Count; i++) { - for (int j = i + 1; j < list.Count; j++) + int f = list[i]; + int i0 = triangles[f * 3], i1 = triangles[f * 3 + 1], i2 = triangles[f * 3 + 2]; + int sharedCount = vertexCounts[i0]; + + if (i1 != i0) + sharedCount += vertexCounts[i1] - pairCounts[VertexPairKey(i0, i1)]; + if (i2 != i0 && i2 != i1) { - if (!FacesShareVertex(faceVerts, list[i], list[j])) + sharedCount += vertexCounts[i2] - pairCounts[VertexPairKey(i0, i2)]; + if (i1 != i0) { - overlapping.Add(list[i]); - overlapping.Add(list[j]); + sharedCount -= pairCounts[VertexPairKey(i1, i2)]; + sharedCount += tripleCounts[new FaceVertexKey(i0, i1, i2)]; } } + + if (sharedCount < list.Count) + overlapping.Add(f); } } return overlapping; } + static long VertexPairKey(int v0, int v1) + { + return v0 < v1 ? ((long)v0 << 32) | (uint)v1 : ((long)v1 << 32) | (uint)v0; + } + + static void IncrementCount(Dictionary counts, TKey key) + { + counts.TryGetValue(key, out int count); + counts[key] = count + 1; + } + // ════════════════════════════════════════════════════════════ // Face adjacency graph // ════════════════════════════════════════════════════════════ diff --git a/Tests/Editor/SpatialPartitionerTests.cs b/Tests/Editor/SpatialPartitionerTests.cs new file mode 100644 index 00000000..1cb03e5f --- /dev/null +++ b/Tests/Editor/SpatialPartitionerTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace SashaRX.UnityMeshLab.Tests +{ + public class SpatialPartitionerTests + { + [Test] + public void PartitionShells_FacesSharingGridCellsAndVertex_DoNotOverlap() + { + var uv = BuildFullBoundsUvs(4); + var triangles = new[] + { + 0, 1, 2, + 0, 3, 4, + 0, 5, 6, + 0, 7, 8 + }; + + var result = PartitionSingleShell(uv, triangles); + + Assert.IsFalse(result.hasOverlap, + "Faces that only meet through a shared vertex must not be treated as UV overlap"); + } + + [Test] + public void PartitionShells_NonAdjacentFaceInSharedGridCells_DetectsOverlap() + { + var uv = BuildFullBoundsUvs(4); + var triangles = new[] + { + 0, 1, 2, + 0, 3, 4, + 0, 5, 6, + 9, 7, 8 + }; + + var result = PartitionSingleShell(uv, triangles); + + Assert.IsTrue(result.hasOverlap, + "A face sharing grid cells but no vertex must be treated as UV overlap"); + } + + static Vector2[] BuildFullBoundsUvs(int faceCount) + { + var uv = new Vector2[faceCount * 2 + 2]; + uv[0] = Vector2.zero; + for (int i = 0; i < faceCount; i++) + { + uv[i * 2 + 1] = Vector2.right; + uv[i * 2 + 2] = Vector2.up; + } + uv[9] = Vector2.zero; + return uv; + } + + static SpatialPartitioner.ShellPartitionResult PartitionSingleShell( + Vector2[] uv, int[] triangles) + { + var shell = new UvShell + { + shellId = 0, + boundsMin = Vector2.zero, + boundsMax = Vector2.one, + faceIndices = new List { 0, 1, 2, 3 } + }; + var vertices = new Vector3[uv.Length]; + + return SpatialPartitioner.PartitionShells( + new List { shell }, uv, triangles, vertices)[0]; + } + } +} diff --git a/Tests/Editor/SpatialPartitionerTests.cs.meta b/Tests/Editor/SpatialPartitionerTests.cs.meta new file mode 100644 index 00000000..d14e7b90 --- /dev/null +++ b/Tests/Editor/SpatialPartitionerTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7b74a1e5596540a38f8440ca7cf95d27 +timeCreated: 1786032000