diff --git a/src/NwcIO/Model/Clash/NwcClashDetector.cs b/src/NwcIO/Model/Clash/NwcClashDetector.cs index 5b31f72..aa41433 100644 --- a/src/NwcIO/Model/Clash/NwcClashDetector.cs +++ b/src/NwcIO/Model/Clash/NwcClashDetector.cs @@ -64,7 +64,18 @@ private static void RunCore( string modelName) { bool sameSelection = ReferenceEquals(meshesA, meshesB) || - (meshesA.Count == meshesB.Count && meshesA.Count > 0 && ReferenceEquals(meshesA[0], meshesB[0])); + meshesA.SequenceEqual(meshesB, ReferenceEqualityComparer.Instance); + Dictionary? firstIndexA = null; + HashSet? meshesInB = null; + if (!sameSelection) + { + firstIndexA = new Dictionary(ReferenceEqualityComparer.Instance); + for (int i = 0; i < meshesA.Count; i++) + { + firstIndexA.TryAdd(meshesA[i], i); + } + meshesInB = new HashSet(meshesB, ReferenceEqualityComparer.Instance); + } bool ignoreSameLayer = test.Rules.Any(r => r.Enabled && r.RuleType == NwcClashRuleType.IgnoreSameLayer); bool ignoreSameGroup = test.Rules.Any(r => r.Enabled && r.RuleType == NwcClashRuleType.IgnoreSameGroup); @@ -74,12 +85,17 @@ private static void RunCore( var meshA = meshesA[i]; var boxA = meshA.GetWorldBoundingBox(); if (boxA.IsEmpty) continue; + bool canVisitReverse = !sameSelection && meshesInB!.Contains(meshA); - int startJ = sameSelection ? i + 1 : 0; - for (int j = startJ; j < meshesB.Count; j++) + for (var j = sameSelection ? i + 1 : 0; j < meshesB.Count; j++) { var meshB = meshesB[j]; if (ReferenceEquals(meshA, meshB) || meshA.Id == meshB.Id) continue; + // The reverse pair was already visited from the earlier mesh in A. + if (canVisitReverse && firstIndexA!.TryGetValue(meshB, out int firstIndex) && firstIndex < i) + { + continue; + } var boxB = meshB.GetWorldBoundingBox(); if (boxB.IsEmpty) continue; @@ -149,7 +165,19 @@ private static void RunCoreMultiModel( List<(NwcMesh Mesh, string ModelName, string? Layer)> itemsB, NwcClashTest test) { - bool sameSelection = itemsA.Select(item => item.Mesh).SequenceEqual(itemsB.Select(item => item.Mesh)); + bool sameSelection = itemsA.Select(item => item.Mesh) + .SequenceEqual(itemsB.Select(item => item.Mesh), ReferenceEqualityComparer.Instance); + Dictionary? firstIndexA = null; + HashSet? meshesInB = null; + if (!sameSelection) + { + firstIndexA = new Dictionary(ReferenceEqualityComparer.Instance); + for (int i = 0; i < itemsA.Count; i++) + { + firstIndexA.TryAdd(itemsA[i].Mesh, i); + } + meshesInB = new HashSet(itemsB.Select(item => item.Mesh), ReferenceEqualityComparer.Instance); + } bool ignoreSameFile = test.Rules.Any(r => r.Enabled && r.RuleType == NwcClashRuleType.IgnoreSameFile); bool ignoreSameLayer = test.Rules.Any(r => r.Enabled && r.RuleType == NwcClashRuleType.IgnoreSameLayer); @@ -158,11 +186,17 @@ private static void RunCoreMultiModel( var itemA = itemsA[i]; var boxA = itemA.Mesh.GetWorldBoundingBox(); if (boxA.IsEmpty) continue; + bool canVisitReverse = !sameSelection && meshesInB!.Contains(itemA.Mesh); - for (int j = sameSelection ? i + 1 : 0; j < itemsB.Count; j++) + for (var j = sameSelection ? i + 1 : 0; j < itemsB.Count; j++) { var itemB = itemsB[j]; if (ReferenceEquals(itemA.Mesh, itemB.Mesh)) continue; + // Mesh IDs can repeat across models; only references identify pairs. + if (canVisitReverse && firstIndexA!.TryGetValue(itemB.Mesh, out int firstIndex) && firstIndex < i) + { + continue; + } if (ignoreSameFile && string.Equals(itemA.ModelName, itemB.ModelName, StringComparison.OrdinalIgnoreCase)) { diff --git a/tests/NwcIO.Tests/ClashDetectiveTests.cs b/tests/NwcIO.Tests/ClashDetectiveTests.cs index 762407d..7b678b5 100644 --- a/tests/NwcIO.Tests/ClashDetectiveTests.cs +++ b/tests/NwcIO.Tests/ClashDetectiveTests.cs @@ -76,6 +76,60 @@ public void MeshClashDetectionIdentifiesInterpenetratingElements() Assert.NotNull(clash.Item2.IfcGuid); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ClashDetection_OverlappingSelectionsReportsEachPairOnce(bool federated, bool includeSharedPair) + { + var mesh1 = CreateBoxMesh(1, new(0, 0, 0), new(2, 2, 2)); + var mesh2 = CreateBoxMesh(2, new(1.5, 0.5, 0.5), new(3.5, 1.5, 1.5)); + var mesh3 = CreateBoxMesh(3, new(3, 0, 0), new(5, 2, 2)); + var builder = new NwcDocumentBuilder().SetFileInfo("Architecture.nwc") + .AddMesh(mesh1) + .AddMesh(mesh2); + if (!federated) + { + builder.AddMesh(mesh3); + } + var doc = builder.Build(); + var test = new NwcClashTest("Overlapping selections"); + test.SelectionA.ExplicitNodeIds.AddRange([1, 2]); + test.SelectionB.ExplicitNodeIds.AddRange(includeSharedPair ? [1, 2, 3] : [1, 3]); + + if (federated) + { + var otherDoc = new NwcDocumentBuilder().SetFileInfo("MEP.nwc").AddMesh(mesh3).Build(); + NwcClashDetector.Run(NwcAssembly.Create("Federated", doc, otherDoc), test); + } + else + { + doc.RunClashDetection(test); + } + + var pairs = test.Results.Select(clash => ( + Math.Min(clash.Item1.Mesh!.Id, clash.Item2.Mesh!.Id), + Math.Max(clash.Item1.Mesh!.Id, clash.Item2.Mesh!.Id))).OrderBy(pair => pair.Item1).ToArray(); + Assert.Equal([(1, 2), (2, 3)], pairs); + } + + [Fact] + public void AssemblyClashDetection_AllVsAllPreservesPairsWithRepeatedMeshIds() + { + var architecture = new NwcDocumentBuilder().SetFileInfo("Architecture.nwc") + .AddMesh(CreateBoxMesh(1, new(0, 0, 0), new(2, 2, 2))) + .Build(); + var mep = new NwcDocumentBuilder().SetFileInfo("MEP.nwc") + .AddMesh(CreateBoxMesh(1, new(1, 1, 1), new(3, 3, 3))) + .Build(); + + var test = NwcAssembly.Create("Federated", architecture, mep).RunClashDetection(); + + var clash = Assert.Single(test.Results); + Assert.NotEqual(clash.Item1.ModelName, clash.Item2.ModelName); + } + [Theory] [InlineData(false, false, 0)] [InlineData(true, false, 2)]