refactor: AliasNodeIdentity dual-field + reuse ModelItemAnalysisHelper

- 新增 IndexPath(COM 树索引路径),复用 ModelItemAnalysisHelper.GetModelItemIndexPaths
- 保留 DisplayPath(父链 DisplayName 拼接)作为降级匹配
- 序列化格式:"IndexPath||DisplayPath",向后兼容纯 DisplayPath
- FindModelItemByNodeKey: IndexPath 优先定位,DisplayPath 降级
- BuildNode: 新增旧格式兼容查找
- 单元测试全面更新覆盖新/旧格式、双字段解析、前缀匹配
This commit is contained in:
tian 2026-05-31 12:52:26 +08:00
parent 56d18c696c
commit 4feb12f6b9
3 changed files with 402 additions and 312 deletions

View File

@ -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<string, string>
{
{ 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<string, string>());
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));
}
}
}

View File

@ -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
{
/// <summary>
/// 稳定的复合节点标识 — 替代 InstanceGuid 用于持久化。
///
/// 基于父链 DisplayName 拼接 + 同级同名节点去重索引 构成唯一 key。
/// 即使 Revit 重新导出 NWC/append 进 NWD只要 DisplayName 层级结构不变,
/// 别名就能正确关联。
///
/// 设计原则:
/// - 序列化格式: "path/to/node#index",便于 SQLite 存储和 JSON 导出
/// - 不可变结构体(值语义),可用作 Dictionary key
/// - 从 ModelItem 构造时在主线程调用Navisworks API
/// - 反序列化/比较时纯字符串操作(可在后台线程安全使用)
///
/// 双字段设计:
/// - <see cref="IndexPath"/>COM API 树索引路径(如 "0,2,5,1"
/// 复用 <see cref="ModelItemAnalysisHelper.GetModelItemIndexPaths"/> 生成。
/// 定位时优先使用,精度高、速度快。
/// - <see cref="DisplayPath"/>:父链 DisplayName 拼接路径(如 "模型/楼层/墙#0"
/// 可读可调试IndexPath 失效时作为降级定位手段。
///
/// 序列化格式ToKey "IndexPath||DisplayPath"
/// 示例: "0,2,5,1||模型/楼层/墙#0"
///
/// 向后兼容:不含 "||" 的 key 按旧格式(纯 DisplayPath解析。
/// </summary>
public readonly struct AliasNodeIdentity : IEquatable<AliasNodeIdentity>
{
/// <summary>父链 DisplayName 拼接路径</summary>
public string HierarchicalPath { get; }
/// <summary>COM 树索引路径(如 "0,2,5,1"),优先用于定位</summary>
public string IndexPath { get; }
/// <summary>同级同名节点中的去重索引0-based</summary>
public int SiblingIndex { get; }
/// <summary>父链 DisplayName 拼接路径 + 同级索引(如 "模型/楼层/墙#0"</summary>
public string DisplayPath { get; }
/// <summary>
/// 构造复合标识
/// 从全量信息构造
/// </summary>
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));
}
// ── 工厂方法 ──
/// <summary>
/// 从 ModelItem 构造复合标识。
/// 注意:必须在主线程中调用,因为涉及 Navisworks API 访问
/// 必须在主线程调用(涉及 Navisworks API + COM API
/// </summary>
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<string>();
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);
}
/// <summary>
/// 序列化为数据库主键字符串
/// 计算同名兄弟节点中的位置
/// </summary>
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;
}
// ── 序列化 ──
/// <summary>
/// 序列化为 DB 主键 / JSON key
/// 格式: "IndexPath||DisplayPath"
/// </summary>
public string ToKey()
{
return $"{HierarchicalPath}#{SiblingIndex}";
if (string.IsNullOrEmpty(IndexPath))
return DisplayPath;
return $"{IndexPath}||{DisplayPath}";
}
/// <summary>
/// 从 key 字符串反序列化
/// 从 key 反序列化
/// </summary>
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);
}
/// <summary>
/// 提前验证 key 字符串格式是否合法(不抛出异常)
/// 提前验证 key 字符串格式是否合法
/// </summary>
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;
/// <summary>
/// 获取 DisplayPath 部分中的 HierarchicalPath去掉尾部 #索引)
/// </summary>
public string GetHierarchicalPath()
{
int hashIdx = DisplayPath.LastIndexOf('#');
return hashIdx > 0 ? DisplayPath.Substring(0, hashIdx) : DisplayPath;
}
/// <summary>
/// 仅检查 HierarchicalPath 前缀匹配 — 用于"批量删除该分支下所有别名"
/// 获取 DisplayPath 部分中的 SiblingIndex
/// </summary>
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;
}
/// <summary>
/// 检查 DisplayPath 是否以指定前缀开头(用于"清除某分支下全部别名"
/// </summary>
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();
}
}

View File

@ -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
// 策略IndexPathCOM 树索引路径优先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;
}
}
/// <summary>
/// 按 COM 索引路径定位 ModelItem
/// 路径格式:"模型索引,一级子索引,二级子索引,..."
/// </summary>
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;
}
/// <summary>
/// 按 DisplayPath 降级定位
/// </summary>
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;
}