Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions src/NwcIO/Model/Diff/NwcDiffEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,21 @@ private static void PerformDiff(
List<DiffItem> revItems,
NwcDiffOptions options)
{
// 1. Build lookup tables: by Guid first, then by composite MatchKey
var baseByGuid = new Dictionary<Guid, DiffItem>();
var baseByKey = new Dictionary<string, DiffItem>(StringComparer.OrdinalIgnoreCase);
// 1. Build lookup tables: by Guid first, then by composite MatchKey. Several baseline items can share a GUID
// (or, across the models of an assembly, a key), so each entry queues them in order and a match takes the
// first one: every baseline item is matched at most once.
var baseByGuid = new Dictionary<Guid, Queue<DiffItem>>();
var baseByKey = new Dictionary<string, Queue<DiffItem>>(StringComparer.OrdinalIgnoreCase);

foreach (var item in baseItems)
{
if (item.ElementGuid.HasValue && item.ElementGuid.Value != Guid.Empty)
{
baseByGuid[item.ElementGuid.Value] = item;
Enqueue(baseByGuid, item.ElementGuid.Value, item);
}
else
{
baseByKey[item.MatchKey] = item;
Enqueue(baseByKey, item.MatchKey, item);
}
}

Expand All @@ -97,11 +99,11 @@ private static void PerformDiff(
DiffItem? baseItem = null;
if (rev.ElementGuid.HasValue && rev.ElementGuid.Value != Guid.Empty)
{
baseByGuid.TryGetValue(rev.ElementGuid.Value, out baseItem);
baseItem = Dequeue(baseByGuid, rev.ElementGuid.Value);
}
if (baseItem is null && !string.IsNullOrEmpty(rev.MatchKey))
{
baseByKey.TryGetValue(rev.MatchKey, out baseItem);
baseItem = Dequeue(baseByKey, rev.MatchKey);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Disambiguate matches by the contributing model

The shared FIFO queue can now match an unchanged element to a different source model.

Reproduction: baseline assembly [A.rvt, B.rvt], revision assembly [B.rvt]. Each document contains one GUID-less mesh named Wall; A's mesh is at X=0 and B's is at X=100. Both acquire the same MatchKey. Main/base correctly reports B as Unchanged and A as Deleted. This PR dequeues A for B, reports it as Modified, and reports the unchanged B mesh as Deleted. Reordering [A,B] to [B,A] also produces two false modifications.

Please retain the contributing model's identity when resolving duplicate match keys (and use it to disambiguate duplicate GUID candidates where appropriate), rather than relying on assembly collection order. Add tests for removing the first model and reordering unchanged models.

}

if (baseItem is null)
Expand Down Expand Up @@ -221,6 +223,22 @@ private static void PerformDiff(
}
}

private static void Enqueue<TKey>(Dictionary<TKey, Queue<DiffItem>> lookup, TKey key, DiffItem item)
where TKey : notnull
{
if (!lookup.TryGetValue(key, out var queue))
{
queue = new Queue<DiffItem>();
lookup[key] = queue;
}

queue.Enqueue(item);
}

private static DiffItem? Dequeue<TKey>(Dictionary<TKey, Queue<DiffItem>> lookup, TKey key)
where TKey : notnull =>
lookup.TryGetValue(key, out var queue) && queue.TryDequeue(out var item) ? item : null;

private static bool PassesFilter(DiffItem item, string? filter)
{
if (string.IsNullOrWhiteSpace(filter)) return true;
Expand Down
68 changes: 68 additions & 0 deletions tests/NwcIO.Tests/DiffEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,72 @@ public void FederatedAssembly_CompareProducesReport()
Assert.Equal("MeshNew", report.Added[0].Name);
Assert.Single(report.Unchanged);
}

private static NwcDocument CreateSharedGuidDocument(Guid guid, int count)
{
var root = new NwcNode { Id = 1, Name = "Root", Class = "Root" };
for (int i = 0; i < count; i++)
{
root.Children.Add(new NwcNode { Id = 2 + i, Name = "Part", Class = "LcRevitData_Element", ElementGuid = guid });
}

return NwcDocument.Create(rootNode: root);
}

[Fact]
public void Compare_DuplicateGuidsInDocument_MatchesEachElementOnce()
{
var guid = Guid.NewGuid();

var report = CreateSharedGuidDocument(guid, 3).Compare(CreateSharedGuidDocument(guid, 3));

Assert.Empty(report.Added);
Assert.Empty(report.Deleted);
Assert.Empty(report.Modified);
Assert.Equal(3, report.Unchanged.Count);
Assert.Equal([2, 3, 4], report.Unchanged.Select(d => d.BaselineNode!.Id).Order());
}

[Fact]
public void Compare_DuplicateGuidRemovedFromRevision_ReportsOneDeletion()
{
var guid = Guid.NewGuid();

var report = CreateSharedGuidDocument(guid, 3).Compare(CreateSharedGuidDocument(guid, 2));

Assert.Empty(report.Added);
Assert.Single(report.Deleted);
Assert.Equal(2, report.Unchanged.Count);
}

[Fact]
public void Compare_GuidSharedAcrossAssemblyModels_MatchesEachElementOnce()
{
var guid = Guid.NewGuid();
var baseline = NwcAssembly.Create("A", CreateSharedGuidDocument(guid, 1), CreateSharedGuidDocument(guid, 1));
var revision = NwcAssembly.Create("B", CreateSharedGuidDocument(guid, 1), CreateSharedGuidDocument(guid, 1));

var report = baseline.Compare(revision);

Assert.Empty(report.Added);
Assert.Empty(report.Deleted);
Assert.Equal(2, report.Unchanged.Count);
}

[Fact]
public void Compare_SameMeshNameAcrossAssemblyModels_MatchesEachElementOnce()
{
var baseline = NwcAssembly.Create("A",
NwcDocument.Create(meshes: [CreateBoxMesh(1, "Wall", new Vector3d(0, 0, 0))]),
NwcDocument.Create(meshes: [CreateBoxMesh(1, "Wall", new Vector3d(0, 0, 0))]));
var revision = NwcAssembly.Create("B",
NwcDocument.Create(meshes: [CreateBoxMesh(1, "Wall", new Vector3d(0, 0, 0))]),
NwcDocument.Create(meshes: [CreateBoxMesh(1, "Wall", new Vector3d(0, 0, 0))]));

var report = baseline.Compare(revision);

Assert.Empty(report.Added);
Assert.Empty(report.Deleted);
Assert.Equal(2, report.Unchanged.Count);
}
}
Loading