diff --git a/UnitTests/Core/AliasTreeTests.cs b/UnitTests/Core/AliasTreeTests.cs index 3bc92ae..52e08de 100644 --- a/UnitTests/Core/AliasTreeTests.cs +++ b/UnitTests/Core/AliasTreeTests.cs @@ -9,141 +9,187 @@ namespace NavisworksTransport.UnitTests.Core [TestClass] public class AliasNodeIdentityTests { - // ── 构造与序列化 ── + // ── 构造 ── [TestMethod] - public void Constructor_WithValidArgs_ShouldSetProperties() + public void Constructor_ShouldSetBothFields() { - var id = new AliasNodeIdentity("building/floor", 2); - Assert.AreEqual("building/floor", id.HierarchicalPath); - Assert.AreEqual(2, id.SiblingIndex); + var id = new AliasNodeIdentity("0,2,5", "building/floor/wall#1"); + Assert.AreEqual("0,2,5", id.IndexPath); + Assert.AreEqual("building/floor/wall#1", id.DisplayPath); + } + + [TestMethod] + public void Constructor_WithEmptyIndexPath_IsValid() + { + var id = new AliasNodeIdentity("", "building/floor"); + Assert.AreEqual("", id.IndexPath); + Assert.AreEqual("building/floor", id.DisplayPath); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Constructor_NullDisplayPath_ShouldThrow() + { + new AliasNodeIdentity("0,0", null); + } + + // ── 序列化 ── + + [TestMethod] + public void ToKey_WithBothFields_ShouldFormatCorrectly() + { + var id = new AliasNodeIdentity("0,2,5", "模型/楼层/墙#0"); + Assert.AreEqual("0,2,5||模型/楼层/墙#0", id.ToKey()); + } + + [TestMethod] + public void ToKey_WithEmptyIndexPath_ShouldReturnDisplayPathOnly() + { + var id = new AliasNodeIdentity("", "模型/楼层/墙#0"); + Assert.AreEqual("模型/楼层/墙#0", id.ToKey()); + } + + [TestMethod] + public void FromKey_NewFormat_ShouldParseCorrectly() + { + var id = AliasNodeIdentity.FromKey("0,2,5||模型/楼层/墙#0"); + Assert.AreEqual("0,2,5", id.IndexPath); + Assert.AreEqual("模型/楼层/墙#0", id.DisplayPath); + } + + [TestMethod] + public void FromKey_OldFormat_ShouldRetroCompatible() + { + var id = AliasNodeIdentity.FromKey("building/floor/wall#2"); + Assert.AreEqual("", id.IndexPath); + Assert.AreEqual("building/floor/wall#2", id.DisplayPath); + } + + [TestMethod] + public void FromKey_OldFormatNoIndex_ShouldParse() + { + var id = AliasNodeIdentity.FromKey("building/floor"); + Assert.AreEqual("", id.IndexPath); + Assert.AreEqual("building/floor", id.DisplayPath); } [TestMethod] [ExpectedException(typeof(ArgumentException))] - public void Constructor_WithEmptyPath_ShouldThrow() + public void FromKey_EmptyString_ShouldThrow() { - new AliasNodeIdentity("", 0); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void Constructor_WithNegativeIndex_ShouldThrow() - { - new AliasNodeIdentity("building/floor", -1); - } - - [TestMethod] - public void ToKey_ShouldReturnCorrectFormat() - { - var id = new AliasNodeIdentity("building/floor/wall", 3); - Assert.AreEqual("building/floor/wall#3", id.ToKey()); - } - - [TestMethod] - public void FromKey_ShouldRestoreIdentity() - { - var restored = AliasNodeIdentity.FromKey("building/floor/wall#3"); - Assert.AreEqual("building/floor/wall", restored.HierarchicalPath); - Assert.AreEqual(3, restored.SiblingIndex); + AliasNodeIdentity.FromKey(""); } [TestMethod] [ExpectedException(typeof(FormatException))] - public void FromKey_WithoutSeparator_ShouldThrow() + public void FromKey_EmptyDisplayAfterSeparator_ShouldThrow() { - AliasNodeIdentity.FromKey("building/floor/wall"); + AliasNodeIdentity.FromKey("0,0||"); + } + + // ── TryParse ── + + [TestMethod] + public void TryParseKey_NewFormat_ShouldSucceed() + { + Assert.IsTrue(AliasNodeIdentity.TryParseKey("0,0||path#0", out var id)); + Assert.AreEqual("0,0", id.IndexPath); + Assert.AreEqual("path#0", id.DisplayPath); } [TestMethod] - [ExpectedException(typeof(FormatException))] - public void FromKey_EmptyIndex_ShouldThrow() + public void TryParseKey_OldFormat_ShouldSucceed() { - AliasNodeIdentity.FromKey("building/floor/wall#"); + Assert.IsTrue(AliasNodeIdentity.TryParseKey("path#0", out var id)); + Assert.AreEqual("", id.IndexPath); } [TestMethod] - [ExpectedException(typeof(FormatException))] - public void FromKey_NonNumericIndex_ShouldThrow() - { - AliasNodeIdentity.FromKey("building/floor/wall#abc"); - } - - // ── TryParseKey ── - - [TestMethod] - public void TryParseKey_ValidKey_ShouldReturnTrue() - { - bool ok = AliasNodeIdentity.TryParseKey("building/floor#0", out var id); - Assert.IsTrue(ok); - Assert.AreEqual("building/floor", id.HierarchicalPath); - Assert.AreEqual(0, id.SiblingIndex); - } - - [TestMethod] - public void TryParseKey_InvalidKey_ShouldReturnFalse() + public void TryParseKey_Empty_ShouldReturnFalse() { Assert.IsFalse(AliasNodeIdentity.TryParseKey("", out _)); - Assert.IsFalse(AliasNodeIdentity.TryParseKey("nohash", out _)); - Assert.IsFalse(AliasNodeIdentity.TryParseKey("hashonly#", out _)); - Assert.IsFalse(AliasNodeIdentity.TryParseKey("path#-1", out _)); - Assert.IsFalse(AliasNodeIdentity.TryParseKey("path#abc", out _)); } - // ── 值语义 ── + // ── 查询辅助 ── [TestMethod] - public void Equals_IdenticalIdentities_ShouldBeEqual() + public void GetHierarchicalPath_WithIndex_ShouldStripSuffix() { - var a = new AliasNodeIdentity("root/a", 0); - var b = new AliasNodeIdentity("root/a", 0); - Assert.AreEqual(a, b); - Assert.IsTrue(a == b); - Assert.IsFalse(a != b); - Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + var id = new AliasNodeIdentity("0,0", "a/b/c#2"); + Assert.AreEqual("a/b/c", id.GetHierarchicalPath()); } [TestMethod] - public void Equals_DifferentPath_ShouldNotBeEqual() + public void GetHierarchicalPath_WithoutIndex_ShouldReturnFull() { - var a = new AliasNodeIdentity("root/a", 0); - var b = new AliasNodeIdentity("root/b", 0); - Assert.AreNotEqual(a, b); + var id = new AliasNodeIdentity("0,0", "a/b/c"); + Assert.AreEqual("a/b/c", id.GetHierarchicalPath()); } [TestMethod] - public void Equals_DifferentIndex_ShouldNotBeEqual() + public void GetSiblingIndex_WithIndex_ShouldReturn() { - var a = new AliasNodeIdentity("root/a", 0); - var b = new AliasNodeIdentity("root/a", 1); - Assert.AreNotEqual(a, b); + var id = new AliasNodeIdentity("0,0", "a/b#3"); + Assert.AreEqual(3, id.GetSiblingIndex()); } - // ── HasPathPrefix ── + [TestMethod] + public void GetSiblingIndex_WithoutIndex_ShouldReturnZero() + { + var id = new AliasNodeIdentity("0,0", "a/b"); + Assert.AreEqual(0, id.GetSiblingIndex()); + } [TestMethod] - public void HasPathPrefix_MatchingPrefix_ShouldReturnTrue() + public void HasPathPrefix_Matching_ShouldReturnTrue() { - var id = new AliasNodeIdentity("building/floor/wall", 0); + var id = new AliasNodeIdentity("0,0", "building/floor/wall#0"); Assert.IsTrue(id.HasPathPrefix("building/floor")); Assert.IsTrue(id.HasPathPrefix("building")); Assert.IsTrue(id.HasPathPrefix("building/floor/wall")); } [TestMethod] - public void HasPathPrefix_NonMatchingPrefix_ShouldReturnFalse() + public void HasPathPrefix_NonMatching_ShouldReturnFalse() { - var id = new AliasNodeIdentity("building/floor/wall", 0); + var id = new AliasNodeIdentity("0,0", "building/floor/wall#0"); Assert.IsFalse(id.HasPathPrefix("building/roof")); Assert.IsFalse(id.HasPathPrefix("Build")); } - // ── ToString ── + // ── 值语义 ── + + [TestMethod] + public void Equals_Identical_ShouldMatch() + { + var a = new AliasNodeIdentity("0,1", "path#0"); + var b = new AliasNodeIdentity("0,1", "path#0"); + Assert.AreEqual(a, b); + Assert.IsTrue(a == b); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentIndexPath_ShouldNotMatch() + { + var a = new AliasNodeIdentity("0,1", "path"); + var b = new AliasNodeIdentity("0,2", "path"); + Assert.AreNotEqual(a, b); + } + + [TestMethod] + public void Equals_DifferentDisplayPath_ShouldNotMatch() + { + var a = new AliasNodeIdentity("0,1", "path/a"); + var b = new AliasNodeIdentity("0,1", "path/b"); + Assert.AreNotEqual(a, b); + } [TestMethod] public void ToString_ShouldMatchToKey() { - var id = new AliasNodeIdentity("a/b/c", 1); + var id = new AliasNodeIdentity("0,1", "path#0"); Assert.AreEqual(id.ToKey(), id.ToString()); } } @@ -157,7 +203,6 @@ namespace NavisworksTransport.UnitTests.Core [TestInitialize] public void Setup() { - // 使用临时文件创建独立的 SQLite 数据库 _dbPath = Path.Combine(Path.GetTempPath(), $"AliasDataStoreTest_{Guid.NewGuid():N}.db"); _connection = new SQLiteConnection($"Data Source={_dbPath};Version=3;"); _connection.Open(); @@ -168,8 +213,7 @@ namespace NavisworksTransport.UnitTests.Core { _connection?.Close(); _connection?.Dispose(); - try { File.Delete(_dbPath); } - catch { /* 清理临时文件 */ } + try { File.Delete(_dbPath); } catch { } } private AliasDataStore CreateStore() @@ -179,8 +223,8 @@ namespace NavisworksTransport.UnitTests.Core return store; } - private AliasNodeIdentity Id(string path, int index = 0) - => new AliasNodeIdentity(path, index); + private AliasNodeIdentity Id(string displayPath, string indexPath = "") + => new AliasNodeIdentity(indexPath, displayPath); // ── CRUD ── @@ -188,36 +232,34 @@ namespace NavisworksTransport.UnitTests.Core public void SetAndGetAlias_ShouldReturnSavedValue() { var store = CreateStore(); - store.SetAlias(Id("building/floor", 0), "1F-办公层"); - string result = store.GetAlias(Id("building/floor", 0)); - Assert.AreEqual("1F-办公层", result); + store.SetAlias(Id("building/floor", "0,1"), "1F-办公层"); + Assert.AreEqual("1F-办公层", store.GetAlias(Id("building/floor", "0,1"))); } [TestMethod] public void GetAlias_Nonexistent_ShouldReturnNull() { var store = CreateStore(); - string result = store.GetAlias(Id("some/nonexistent", 0)); - Assert.IsNull(result); + Assert.IsNull(store.GetAlias(Id("nonexistent"))); } [TestMethod] public void SetAlias_Overwrite_ShouldUpdateValue() { var store = CreateStore(); - store.SetAlias(Id("building/floor", 0), "旧名称"); - store.SetAlias(Id("building/floor", 0), "新名称"); - Assert.AreEqual("新名称", store.GetAlias(Id("building/floor", 0))); + store.SetAlias(Id("wall", "0,0"), "旧名称"); + store.SetAlias(Id("wall", "0,0"), "新名称"); + Assert.AreEqual("新名称", store.GetAlias(Id("wall", "0,0"))); } [TestMethod] public void SetAlias_EmptyString_ShouldDelete() { var store = CreateStore(); - store.SetAlias(Id("building/floor", 0), "原本"); + store.SetAlias(Id("x", "0,0"), "原本"); Assert.AreEqual(1, store.Count); - store.SetAlias(Id("building/floor", 0), " "); - Assert.IsNull(store.GetAlias(Id("building/floor", 0))); + store.SetAlias(Id("x", "0,0"), " "); + Assert.IsNull(store.GetAlias(Id("x", "0,0"))); Assert.AreEqual(0, store.Count); } @@ -225,129 +267,95 @@ namespace NavisworksTransport.UnitTests.Core public void DeleteAlias_ShouldRemoveEntry() { var store = CreateStore(); - store.SetAlias(Id("building/floor", 0), "别名"); + store.SetAlias(Id("x"), "别名"); Assert.AreEqual(1, store.Count); - store.DeleteAlias(Id("building/floor", 0)); + store.DeleteAlias(Id("x")); Assert.AreEqual(0, store.Count); - Assert.IsNull(store.GetAlias(Id("building/floor", 0))); } - // ── 批量操作 ── + // ── 批量 ── [TestMethod] public void DeleteByPathPrefix_ShouldRemoveAllUnderPath() { var store = CreateStore(); - store.SetAlias(Id("building/floor/a", 0), "别名1"); - store.SetAlias(Id("building/floor/b", 0), "别名2"); - store.SetAlias(Id("building/roof/c", 0), "别名3"); + store.SetAlias(Id("building/floor/1")); + store.SetAlias(Id("building/floor/2")); + store.SetAlias(Id("building/roof/3")); store.DeleteAliasesByPathPrefix("building/floor"); - Assert.IsNull(store.GetAlias(Id("building/floor/a", 0))); - Assert.IsNull(store.GetAlias(Id("building/floor/b", 0))); - Assert.AreEqual("别名3", store.GetAlias(Id("building/roof/c", 0))); + Assert.IsNull(store.GetAlias(Id("building/floor/1"))); + Assert.IsNull(store.GetAlias(Id("building/floor/2"))); + Assert.IsNotNull(store.GetAlias(Id("building/roof/3"))); } [TestMethod] - public void LoadAll_ShouldReturnAllEntries() + public void LoadAll_ShouldReturnAll() { var store = CreateStore(); - store.SetAlias(Id("a", 0), "别名A"); - store.SetAlias(Id("b", 1), "别名B"); - store.SetAlias(Id("c", 2), "别名C"); + store.SetAlias(Id("a", "0,0"), "别名A"); + store.SetAlias(Id("b", "1,0"), "别名B"); var all = store.LoadAll(); - Assert.AreEqual(3, all.Count); - Assert.AreEqual("别名A", all[Id("a", 0).ToKey()]); - Assert.AreEqual("别名B", all[Id("b", 1).ToKey()]); - Assert.AreEqual("别名C", all[Id("c", 2).ToKey()]); + Assert.AreEqual(2, all.Count); + Assert.AreEqual("别名A", all[Id("a", "0,0").ToKey()]); + Assert.AreEqual("别名B", all[Id("b", "1,0").ToKey()]); } [TestMethod] - public void Count_EmptyStore_ShouldBeZero() + public void Count_ShouldReflectInserts() { var store = CreateStore(); Assert.AreEqual(0, store.Count); - } - - [TestMethod] - public void Count_AfterInsert_ShouldIncrease() - { - var store = CreateStore(); - store.SetAlias(Id("a", 0), "alias1"); - store.SetAlias(Id("b", 0), "alias2"); + store.SetAlias(Id("a"), "alias1"); + store.SetAlias(Id("b"), "alias2"); Assert.AreEqual(2, store.Count); } - // ── 批量导入 ── + // ── ImportBatch ── [TestMethod] - public void ImportBatch_ShouldMergeEntries() + public void ImportBatch_ShouldMergeAndOverwrite() { var store = CreateStore(); - store.SetAlias(Id("existing", 0), "保留值"); + store.SetAlias(Id("existing", "0,0"), "保留值"); var entries = new System.Collections.Generic.Dictionary { - { Id("existing", 0).ToKey(), "覆盖值" }, - { Id("new", 0).ToKey(), "新值" } + { Id("existing", "0,0").ToKey(), "覆盖值" }, + { Id("new", "1,0").ToKey(), "新值" } }; store.ImportBatch(entries); - Assert.AreEqual("覆盖值", store.GetAlias(Id("existing", 0))); - Assert.AreEqual("新值", store.GetAlias(Id("new", 0))); + Assert.AreEqual("覆盖值", store.GetAlias(Id("existing", "0,0"))); + Assert.AreEqual("新值", store.GetAlias(Id("new", "1,0"))); Assert.AreEqual(2, store.Count); } - [TestMethod] - public void ImportBatch_EmptyDict_ShouldNotAffectStore() - { - var store = CreateStore(); - store.SetAlias(Id("a", 0), "值"); - store.ImportBatch(new System.Collections.Generic.Dictionary()); - Assert.AreEqual(1, store.Count); - } - - // ── 清除全部 ── - [TestMethod] public void ClearAll_ShouldRemoveEverything() { var store = CreateStore(); - store.SetAlias(Id("a", 0), "别名1"); - store.SetAlias(Id("b", 1), "别名2"); + store.SetAlias(Id("a"), "别名1"); + store.SetAlias(Id("b"), "别名2"); store.ClearAll(); Assert.AreEqual(0, store.Count); } - // ── 同名兄弟节点去重 ── + // ── 新格式 → 旧格式 兼容 ── [TestMethod] - public void SiblingNodes_WithDifferentIndex_ShouldStoreSeparately() + public void OldFormatKey_StillWorks() { var store = CreateStore(); - store.SetAlias(Id("floor/wall", 0), "墙-1"); - store.SetAlias(Id("floor/wall", 1), "墙-2"); - store.SetAlias(Id("floor/wall", 2), "墙-3"); + string oldKey = "building/floor/wall#0"; + store.SetAlias(AliasNodeIdentity.FromKey(oldKey), "别名"); + Assert.AreEqual("别名", store.GetAlias(AliasNodeIdentity.FromKey(oldKey))); - Assert.AreEqual("墙-1", store.GetAlias(Id("floor/wall", 0))); - Assert.AreEqual("墙-2", store.GetAlias(Id("floor/wall", 1))); - Assert.AreEqual("墙-3", store.GetAlias(Id("floor/wall", 2))); - Assert.AreEqual(3, store.Count); - } - - [TestMethod] - public void LoadAll_SiblingNodes_ShouldReturnAll() - { - var store = CreateStore(); - store.SetAlias(Id("same/path", 0), "第一个"); - store.SetAlias(Id("same/path", 1), "第二个"); - - var all = store.LoadAll(); - Assert.AreEqual(2, all.Count); - Assert.AreEqual("第一个", all["same/path#0"]); - Assert.AreEqual("第二个", all["same/path#1"]); + // 同一个节点用新格式读取 + var newId = new AliasNodeIdentity("", "building/floor/wall#0"); + Assert.AreEqual("别名", store.GetAlias(newId)); } } } diff --git a/src/Core/AliasTree/AliasNodeIdentity.cs b/src/Core/AliasTree/AliasNodeIdentity.cs index 244c3f8..299dec8 100644 --- a/src/Core/AliasTree/AliasNodeIdentity.cs +++ b/src/Core/AliasTree/AliasNodeIdentity.cs @@ -1,55 +1,59 @@ using Autodesk.Navisworks.Api; +using NavisworksTransport.Utils; using System; using System.Collections.Generic; -using System.Text; +using System.Linq; namespace NavisworksTransport.Core.AliasTree { /// /// 稳定的复合节点标识 — 替代 InstanceGuid 用于持久化。 - /// - /// 基于父链 DisplayName 拼接 + 同级同名节点去重索引 构成唯一 key。 - /// 即使 Revit 重新导出 NWC/append 进 NWD,只要 DisplayName 层级结构不变, - /// 别名就能正确关联。 - /// - /// 设计原则: - /// - 序列化格式: "path/to/node#index",便于 SQLite 存储和 JSON 导出 - /// - 不可变结构体(值语义),可用作 Dictionary key - /// - 从 ModelItem 构造时在主线程调用(Navisworks API) - /// - 反序列化/比较时纯字符串操作(可在后台线程安全使用) + /// + /// 双字段设计: + /// - :COM API 树索引路径(如 "0,2,5,1"), + /// 复用 生成。 + /// 定位时优先使用,精度高、速度快。 + /// - :父链 DisplayName 拼接路径(如 "模型/楼层/墙#0"), + /// 可读可调试,IndexPath 失效时作为降级定位手段。 + /// + /// 序列化格式(ToKey): "IndexPath||DisplayPath" + /// 示例: "0,2,5,1||模型/楼层/墙#0" + /// + /// 向后兼容:不含 "||" 的 key 按旧格式(纯 DisplayPath)解析。 /// public readonly struct AliasNodeIdentity : IEquatable { - /// 父链 DisplayName 拼接路径 - public string HierarchicalPath { get; } + /// COM 树索引路径(如 "0,2,5,1"),优先用于定位 + public string IndexPath { get; } - /// 同级同名节点中的去重索引(0-based) - public int SiblingIndex { get; } + /// 父链 DisplayName 拼接路径 + 同级索引(如 "模型/楼层/墙#0") + public string DisplayPath { get; } /// - /// 构造复合标识 + /// 从全量信息构造 /// - public AliasNodeIdentity(string hierarchicalPath, int siblingIndex) + public AliasNodeIdentity(string indexPath, string displayPath) { - if (string.IsNullOrEmpty(hierarchicalPath)) - throw new ArgumentException("路径不能为空", nameof(hierarchicalPath)); - if (siblingIndex < 0) - throw new ArgumentOutOfRangeException(nameof(siblingIndex), "索引不能为负"); - - HierarchicalPath = hierarchicalPath; - SiblingIndex = siblingIndex; + IndexPath = indexPath ?? string.Empty; + DisplayPath = displayPath ?? throw new ArgumentNullException(nameof(displayPath)); } + // ── 工厂方法 ── + /// /// 从 ModelItem 构造复合标识。 - /// 注意:必须在主线程中调用,因为涉及 Navisworks API 访问。 + /// 必须在主线程调用(涉及 Navisworks API + COM API)。 /// public static AliasNodeIdentity FromModelItem(ModelItem item) { if (item == null) throw new ArgumentNullException(nameof(item)); - // 1. 沿 Parent 链收集 DisplayName + // 1. IndexPath — 复用 ModelItemAnalysisHelper + var indexPaths = ModelItemAnalysisHelper.GetModelItemIndexPaths(item); + string indexPath = indexPaths.FirstOrDefault() ?? string.Empty; + + // 2. DisplayPath — 父链 DisplayName + 同级索引 var parts = new List(); var current = item; while (current != null) @@ -57,134 +61,151 @@ namespace NavisworksTransport.Core.AliasTree parts.Insert(0, current.DisplayName ?? "?"); current = current.Parent; } + string displayPath = string.Join("/", parts); - var path = string.Join("/", parts); + // 3. 同级同名节点去重索引 + int siblingIndex = ComputeSiblingIndex(item); - // 2. 计算同级同名节点中的索引 - int siblingIndex = 0; - int matchIndex = 0; - bool foundSelf = false; + string displayPathWithIndex = siblingIndex > 0 + ? $"{displayPath}#{siblingIndex}" + : displayPath; - var parent = item.Parent; - if (parent != null) - { - foreach (var child in parent.Children) - { - if (string.Equals(child.DisplayName, item.DisplayName, StringComparison.Ordinal)) - { - if (child.Equals(item)) - { - foundSelf = true; - siblingIndex = matchIndex; - break; - } - matchIndex++; - } - } - } - - if (!foundSelf) - { - // 若未在父节点中找到自身(理论上不应发生),使用 0 - siblingIndex = 0; - } - - return new AliasNodeIdentity(path, siblingIndex); + return new AliasNodeIdentity(indexPath, displayPathWithIndex); } /// - /// 序列化为数据库主键字符串 + /// 计算同名兄弟节点中的位置 + /// + private static int ComputeSiblingIndex(ModelItem item) + { + var parent = item.Parent; + if (parent == null) return 0; + + string targetName = item.DisplayName; + int matchIndex = 0; + + foreach (var child in parent.Children) + { + if (child.Equals(item)) + return matchIndex; + if (string.Equals(child.DisplayName, targetName, StringComparison.Ordinal)) + matchIndex++; + } + + return 0; + } + + // ── 序列化 ── + + /// + /// 序列化为 DB 主键 / JSON key + /// 格式: "IndexPath||DisplayPath" /// public string ToKey() { - return $"{HierarchicalPath}#{SiblingIndex}"; + if (string.IsNullOrEmpty(IndexPath)) + return DisplayPath; + return $"{IndexPath}||{DisplayPath}"; } /// - /// 从 key 字符串反序列化 + /// 从 key 反序列化 /// public static AliasNodeIdentity FromKey(string key) { if (string.IsNullOrEmpty(key)) throw new ArgumentException("key 不能为空", nameof(key)); - int separatorIndex = key.LastIndexOf('#'); - if (separatorIndex < 0 || separatorIndex >= key.Length - 1) - throw new FormatException($"无法从 key 解析 AliasNodeIdentity: {key}"); + // 检查是否有双字段分隔符 + int separatorIndex = key.IndexOf("||", StringComparison.Ordinal); + if (separatorIndex >= 0) + { + string indexPath = key.Substring(0, separatorIndex); + string displayPath = key.Substring(separatorIndex + 2); + if (string.IsNullOrEmpty(displayPath)) + throw new FormatException($"DisplayPath 为空: {key}"); + return new AliasNodeIdentity(indexPath, displayPath); + } - string path = key.Substring(0, separatorIndex); - string indexStr = key.Substring(separatorIndex + 1); - - if (!int.TryParse(indexStr, out int siblingIndex)) - throw new FormatException($"无法解析 sibling index: {indexStr}"); - - return new AliasNodeIdentity(path, siblingIndex); + // 向后兼容:纯 DisplayPath 格式(旧数据) + return new AliasNodeIdentity(string.Empty, key); } /// - /// 提前验证 key 字符串格式是否合法(不抛出异常) + /// 提前验证 key 字符串格式是否合法 /// public static bool TryParseKey(string key, out AliasNodeIdentity identity) { identity = default; if (string.IsNullOrEmpty(key)) return false; - int separatorIndex = key.LastIndexOf('#'); - if (separatorIndex < 0 || separatorIndex >= key.Length - 1) return false; + try + { + identity = FromKey(key); + return true; + } + catch + { + return false; + } + } - string path = key.Substring(0, separatorIndex); - string indexStr = key.Substring(separatorIndex + 1); + // ── 查询辅助 ── - if (!int.TryParse(indexStr, out int siblingIndex)) return false; - if (string.IsNullOrEmpty(path)) return false; - if (siblingIndex < 0) return false; - - identity = new AliasNodeIdentity(path, siblingIndex); - return true; + /// + /// 获取 DisplayPath 部分中的 HierarchicalPath(去掉尾部 #索引) + /// + public string GetHierarchicalPath() + { + int hashIdx = DisplayPath.LastIndexOf('#'); + return hashIdx > 0 ? DisplayPath.Substring(0, hashIdx) : DisplayPath; } /// - /// 仅检查 HierarchicalPath 前缀匹配 — 用于"批量删除该分支下所有别名" + /// 获取 DisplayPath 部分中的 SiblingIndex + /// + public int GetSiblingIndex() + { + int hashIdx = DisplayPath.LastIndexOf('#'); + if (hashIdx < 0 || hashIdx >= DisplayPath.Length - 1) return 0; + string indexStr = DisplayPath.Substring(hashIdx + 1); + int.TryParse(indexStr, out int idx); + return idx; + } + + /// + /// 检查 DisplayPath 是否以指定前缀开头(用于"清除某分支下全部别名") /// public bool HasPathPrefix(string prefix) { - return HierarchicalPath.StartsWith(prefix, StringComparison.Ordinal); + return GetHierarchicalPath().StartsWith(prefix, StringComparison.Ordinal); } // ── 值语义 ── public bool Equals(AliasNodeIdentity other) { - return string.Equals(HierarchicalPath, other.HierarchicalPath, StringComparison.Ordinal) - && SiblingIndex == other.SiblingIndex; + return string.Equals(IndexPath, other.IndexPath, StringComparison.Ordinal) + && string.Equals(DisplayPath, other.DisplayPath, StringComparison.Ordinal); } public override bool Equals(object obj) - { - return obj is AliasNodeIdentity other && Equals(other); - } + => obj is AliasNodeIdentity other && Equals(other); public override int GetHashCode() { unchecked { - return (HierarchicalPath.GetHashCode() * 397) ^ SiblingIndex; + return (IndexPath?.GetHashCode() ?? 0 * 397) ^ (DisplayPath?.GetHashCode() ?? 0); } } public static bool operator ==(AliasNodeIdentity left, AliasNodeIdentity right) - { - return left.Equals(right); - } + => left.Equals(right); public static bool operator !=(AliasNodeIdentity left, AliasNodeIdentity right) - { - return !left.Equals(right); - } + => !left.Equals(right); - public override string ToString() - { - return ToKey(); - } + public override string ToString() => ToKey(); } } diff --git a/src/UI/WPF/Views/AliasTreeControl.xaml.cs b/src/UI/WPF/Views/AliasTreeControl.xaml.cs index a4c7bd7..4e2aaaa 100644 --- a/src/UI/WPF/Views/AliasTreeControl.xaml.cs +++ b/src/UI/WPF/Views/AliasTreeControl.xaml.cs @@ -237,6 +237,12 @@ namespace NavisworksTransport.UI.WPF.Views string alias = null; aliasMap?.TryGetValue(key, out alias); + // 旧格式兼容:也尝试用纯 DisplayPath 查找 + if (alias == null && !string.IsNullOrEmpty(identity.IndexPath)) + { + aliasMap?.TryGetValue(identity.DisplayPath, out alias); + } + var node = new AliasNodeViewModel { NodeKey = key, @@ -247,8 +253,7 @@ namespace NavisworksTransport.UI.WPF.Views _nodeMap[key] = node; - // 子节点(第一层不懒加载,展开时在 Expanded 事件中再填充) - // 但先加一个占位节点让 TreeView 显示展开箭头 + // 第一层放占位节点以显示展开箭头 if (node.ChildCount > 0) { node.Children.Add(new AliasNodeViewModel @@ -299,6 +304,7 @@ namespace NavisworksTransport.UI.WPF.Views // ============================================================ // 从 NodeKey 回溯定位 ModelItem + // 策略:IndexPath(COM 树索引路径)优先,DisplayPath 降级 // ============================================================ private Autodesk.Navisworks.Api.ModelItem FindModelItemByNodeKey(string key) @@ -309,57 +315,112 @@ namespace NavisworksTransport.UI.WPF.Views try { var doc = NavisApplication.ActiveDocument; - if (doc == null) return null; + if (doc == null || doc.Models == null) return null; - // 沿 HierarchicalPath 匹配 DisplayName - string[] parts = identity.HierarchicalPath.Split('/'); - // parts[0] 是模型名/根节点名 - // 遍历 Models 找匹配的根 - foreach (var model in doc.Models) + // 策略 1:从 IndexPath 定位(最快、最精确) + if (!string.IsNullOrEmpty(identity.IndexPath)) { - var current = model.RootItem; - if (current == null) continue; - - // 检查根节点 - if (parts.Length > 0 && current.DisplayName != parts[0]) - continue; - - for (int i = 1; i < parts.Length; i++) - { - string targetName = parts[i]; - Autodesk.Navisworks.Api.ModelItem matchedChild = null; - foreach (var child in current.Children) - { - if (child.DisplayName == targetName) - { - matchedChild = child; - break; - } - } - if (matchedChild == null) return null; - current = matchedChild; - } - - // 到了叶子层级,用 SiblingIndex 在同名兄弟中定位 - if (identity.SiblingIndex > 0) - { - int idx = 0; - foreach (var child in current.Children) - { - if (child.DisplayName == parts.Last()) - { - if (idx == identity.SiblingIndex) - return child; - idx++; - } - } - return null; - } - - return current; + var item = LocateByIndexPath(doc, identity.IndexPath); + if (item != null && ModelItemAnalysisHelper.IsModelItemValid(item)) + return item; } + + // 策略 2:从 DisplayPath 降级定位 + return LocateByDisplayPath(doc, identity); + } + catch + { + return null; + } + } + + /// + /// 按 COM 索引路径定位 ModelItem + /// 路径格式:"模型索引,一级子索引,二级子索引,..." + /// + private static Autodesk.Navisworks.Api.ModelItem LocateByIndexPath( + Autodesk.Navisworks.Api.Document doc, string indexPath) + { + string[] parts = indexPath.Split(','); + if (parts.Length == 0) return null; + + if (!int.TryParse(parts[0], out int modelIndex)) + return null; + + // 取模型 + if (modelIndex < 0 || modelIndex >= doc.Models.Count) + return null; + + var model = doc.Models[modelIndex]; + if (model?.RootItem == null) return null; + + Autodesk.Navisworks.Api.ModelItem current = model.RootItem; + + for (int i = 1; i < parts.Length; i++) + { + if (!int.TryParse(parts[i], out int childIndex)) + return null; + + var childrenList = current.Children.ToList(); + if (childIndex < 0 || childIndex >= childrenList.Count) + return null; + + current = childrenList[childIndex]; + } + + return current; + } + + /// + /// 按 DisplayPath 降级定位 + /// + private static Autodesk.Navisworks.Api.ModelItem LocateByDisplayPath( + Autodesk.Navisworks.Api.Document doc, AliasNodeIdentity identity) + { + string[] parts = identity.GetHierarchicalPath().Split('/'); + if (parts.Length == 0) return null; + + // parts[0] 是模型名/根节点名,匹配模型 + foreach (var model in doc.Models) + { + var current = model.RootItem; + if (current == null) continue; + if (current.DisplayName != parts[0]) continue; + + for (int i = 1; i < parts.Length; i++) + { + string targetName = parts[i]; + Autodesk.Navisworks.Api.ModelItem matched = null; + foreach (var child in current.Children) + { + if (child.DisplayName == targetName) + { + matched = child; + break; + } + } + if (matched == null) return null; + current = matched; + } + + // 同级同名去重索引 + int siblingIndex = identity.GetSiblingIndex(); + if (siblingIndex > 0) + { + int idx = 0; + foreach (var child in current.Children) + { + if (child.DisplayName == parts.Last()) + { + if (idx == siblingIndex) return child; + idx++; + } + } + return null; + } + + return current; } - catch { } return null; }