diff --git a/NavisworksTransport.UnitTests.csproj b/NavisworksTransport.UnitTests.csproj
index 62784fb..c78f83f 100644
--- a/NavisworksTransport.UnitTests.csproj
+++ b/NavisworksTransport.UnitTests.csproj
@@ -52,10 +52,15 @@
+
+ ..\TransportPlugin\packages\Stub.System.Data.SQLite.Core.NetFramework.1.0.118.0\lib\net46\System.Data.SQLite.dll
+ True
+
+
diff --git a/TransportPlugin.csproj b/TransportPlugin.csproj
index 4f21e45..63376ee 100644
--- a/TransportPlugin.csproj
+++ b/TransportPlugin.csproj
@@ -146,6 +146,8 @@
+
+
diff --git a/UnitTests/Core/AliasTreeTests.cs b/UnitTests/Core/AliasTreeTests.cs
new file mode 100644
index 0000000..3bc92ae
--- /dev/null
+++ b/UnitTests/Core/AliasTreeTests.cs
@@ -0,0 +1,353 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NavisworksTransport.Core.AliasTree;
+using System;
+using System.Data.SQLite;
+using System.IO;
+
+namespace NavisworksTransport.UnitTests.Core
+{
+ [TestClass]
+ public class AliasNodeIdentityTests
+ {
+ // ── 构造与序列化 ──
+
+ [TestMethod]
+ public void Constructor_WithValidArgs_ShouldSetProperties()
+ {
+ var id = new AliasNodeIdentity("building/floor", 2);
+ Assert.AreEqual("building/floor", id.HierarchicalPath);
+ Assert.AreEqual(2, id.SiblingIndex);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentException))]
+ public void Constructor_WithEmptyPath_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);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(FormatException))]
+ public void FromKey_WithoutSeparator_ShouldThrow()
+ {
+ AliasNodeIdentity.FromKey("building/floor/wall");
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(FormatException))]
+ public void FromKey_EmptyIndex_ShouldThrow()
+ {
+ AliasNodeIdentity.FromKey("building/floor/wall#");
+ }
+
+ [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()
+ {
+ 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()
+ {
+ 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());
+ }
+
+ [TestMethod]
+ public void Equals_DifferentPath_ShouldNotBeEqual()
+ {
+ var a = new AliasNodeIdentity("root/a", 0);
+ var b = new AliasNodeIdentity("root/b", 0);
+ Assert.AreNotEqual(a, b);
+ }
+
+ [TestMethod]
+ public void Equals_DifferentIndex_ShouldNotBeEqual()
+ {
+ var a = new AliasNodeIdentity("root/a", 0);
+ var b = new AliasNodeIdentity("root/a", 1);
+ Assert.AreNotEqual(a, b);
+ }
+
+ // ── HasPathPrefix ──
+
+ [TestMethod]
+ public void HasPathPrefix_MatchingPrefix_ShouldReturnTrue()
+ {
+ var id = new AliasNodeIdentity("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()
+ {
+ var id = new AliasNodeIdentity("building/floor/wall", 0);
+ Assert.IsFalse(id.HasPathPrefix("building/roof"));
+ Assert.IsFalse(id.HasPathPrefix("Build"));
+ }
+
+ // ── ToString ──
+
+ [TestMethod]
+ public void ToString_ShouldMatchToKey()
+ {
+ var id = new AliasNodeIdentity("a/b/c", 1);
+ Assert.AreEqual(id.ToKey(), id.ToString());
+ }
+ }
+
+ [TestClass]
+ public class AliasDataStoreTests
+ {
+ private string _dbPath;
+ private SQLiteConnection _connection;
+
+ [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();
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ _connection?.Close();
+ _connection?.Dispose();
+ try { File.Delete(_dbPath); }
+ catch { /* 清理临时文件 */ }
+ }
+
+ private AliasDataStore CreateStore()
+ {
+ var store = new AliasDataStore(_connection);
+ store.EnsureTableExists();
+ return store;
+ }
+
+ private AliasNodeIdentity Id(string path, int index = 0)
+ => new AliasNodeIdentity(path, index);
+
+ // ── CRUD ──
+
+ [TestMethod]
+ 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);
+ }
+
+ [TestMethod]
+ public void GetAlias_Nonexistent_ShouldReturnNull()
+ {
+ var store = CreateStore();
+ string result = store.GetAlias(Id("some/nonexistent", 0));
+ Assert.IsNull(result);
+ }
+
+ [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)));
+ }
+
+ [TestMethod]
+ public void SetAlias_EmptyString_ShouldDelete()
+ {
+ var store = CreateStore();
+ store.SetAlias(Id("building/floor", 0), "原本");
+ Assert.AreEqual(1, store.Count);
+ store.SetAlias(Id("building/floor", 0), " ");
+ Assert.IsNull(store.GetAlias(Id("building/floor", 0)));
+ Assert.AreEqual(0, store.Count);
+ }
+
+ [TestMethod]
+ public void DeleteAlias_ShouldRemoveEntry()
+ {
+ var store = CreateStore();
+ store.SetAlias(Id("building/floor", 0), "别名");
+ Assert.AreEqual(1, store.Count);
+ store.DeleteAlias(Id("building/floor", 0));
+ 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.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)));
+ }
+
+ [TestMethod]
+ public void LoadAll_ShouldReturnAllEntries()
+ {
+ var store = CreateStore();
+ store.SetAlias(Id("a", 0), "别名A");
+ store.SetAlias(Id("b", 1), "别名B");
+ store.SetAlias(Id("c", 2), "别名C");
+
+ 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()]);
+ }
+
+ [TestMethod]
+ public void Count_EmptyStore_ShouldBeZero()
+ {
+ 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");
+ Assert.AreEqual(2, store.Count);
+ }
+
+ // ── 批量导入 ──
+
+ [TestMethod]
+ public void ImportBatch_ShouldMergeEntries()
+ {
+ var store = CreateStore();
+ store.SetAlias(Id("existing", 0), "保留值");
+
+ var entries = new System.Collections.Generic.Dictionary
+ {
+ { Id("existing", 0).ToKey(), "覆盖值" },
+ { Id("new", 0).ToKey(), "新值" }
+ };
+ store.ImportBatch(entries);
+
+ Assert.AreEqual("覆盖值", store.GetAlias(Id("existing", 0)));
+ Assert.AreEqual("新值", store.GetAlias(Id("new", 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.ClearAll();
+ Assert.AreEqual(0, store.Count);
+ }
+
+ // ── 同名兄弟节点去重 ──
+
+ [TestMethod]
+ public void SiblingNodes_WithDifferentIndex_ShouldStoreSeparately()
+ {
+ var store = CreateStore();
+ store.SetAlias(Id("floor/wall", 0), "墙-1");
+ store.SetAlias(Id("floor/wall", 1), "墙-2");
+ store.SetAlias(Id("floor/wall", 2), "墙-3");
+
+ 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"]);
+ }
+ }
+}
diff --git a/doc/design/2026/别名树实施方案.md b/doc/design/2026/别名树实施方案.md
new file mode 100644
index 0000000..d5032df
--- /dev/null
+++ b/doc/design/2026/别名树实施方案.md
@@ -0,0 +1,660 @@
+# 别名树(节点重命名)实施方案
+
+> 基于 [节点重命名参考方案.md](./节点重命名参考方案.md) 的评估与完善。
+> 对应评估结论中 P0/P1/P2 的全部整改点。
+
+---
+
+## 1. 目标
+
+为用户提供「给 Selection Tree 中任意节点打别名」的能力,解决 Revit 导入后 DisplayName 不可读(全叫"楼层""墙""管道")、无法靠名字快速定位的问题。
+
+不做原生树扩展,走 **独立别名面板 + 自管数据层** 路线。
+
+---
+
+## 2. 架构概览
+
+```
+┌──────────────────────────┐
+│ Navisworks 主窗口 │
+│ ┌──────────┬──────────┐ │
+│ │ Selection │ 3D View │ │
+│ │ Tree │ │ │
+│ │(原生只读) │ │ │
+│ ├──────────┤ │ │
+│ │ 别名导航树 │ │ │ ← 新增独立 DockPanePlugin
+│ │(可编辑别名)│ │ │
+│ └──────────┴──────────┘ │
+│ │
+│ SQLite: DocName.db │
+│ ├─ Paths (已有) │
+│ ├─ Collisions (已有) │
+│ └─ AliasMap (新增) │ ← 别名持久化
+└──────────────────────────┘
+```
+
+独立 `DockPanePlugin`,不并入 `LogisticsControlPanel`。用户在 **View → Docking Windows → 别名导航树** 打开后可手动 dock 到 Selection Tree 下方。
+
+---
+
+## 3. 文件清单(新增 9 个文件)
+
+| 文件 | 职责 |
+|------|------|
+| `src/Core/AliasTree/AliasTreePlugin.cs` | DockPanePlugin 入口 + ElementHost |
+| `src/Core/AliasTree/AliasNodeIdentity.cs` | 稳定的复合节点标识(替代 InstanceGuid) |
+| `src/Core/AliasTree/AliasNodeViewModel.cs` | 单节点 VM,含 INotifyPropertyChanged |
+| `src/Core/AliasTree/AliasDataStore.cs` | 别名数据的 CRUD + 与 PathDatabase 对接 |
+| `src/UI/WPF/Views/AliasTreeControl.xaml` | TreeView 布局 + 图标工具栏 + 右键菜单 |
+| `src/UI/WPF/Views/AliasTreeControl.xaml.cs` | 核心逻辑:联动、编辑、批量、高亮、导入导出 |
+| `src/UI/WPF/Views/BatchAliasDialog.xaml` | 批量编辑对话框 |
+| `src/UI/WPF/Views/BatchAliasDialog.xaml.cs` | 批量编辑逻辑 |
+| `src/UI/WPF/Resources/AliasTreeIcons.xaml` | 工具栏图标(Path Geometry 矢量图标) |
+
+---
+
+## 4. 稳定节点标识设计(核心创新点)
+
+### 4.1 为什么不用 InstanceGuid
+
+Revit 重新导出 NWC 再 append 进 NWD 后,`InstanceGuid` 可能整体变化。别名必须跨文件更新存活。
+
+### 4.2 复合标识方案
+
+```csharp
+public struct AliasNodeIdentity
+{
+ /// 沿 Parent 链拼接的路径字符串
+ public string HierarchicalPath { get; }
+
+ /// 同级同名节点的去重索引(0-based)
+ public int SiblingIndex { get; }
+
+ /// 序列化为 DB 主键
+ public string ToKey() => $"{HierarchicalPath}#{SiblingIndex}";
+
+ public static AliasNodeIdentity FromModelItem(ModelItem item)
+ {
+ var parts = new List();
+ var current = item;
+ while (current != null)
+ {
+ parts.Insert(0, current.DisplayName ?? "?");
+ current = current.Parent;
+ }
+
+ var path = string.Join("/", parts);
+ // 计算同级同名节点中的位置
+ var parent = item.Parent;
+ int siblingIndex = 0;
+ int matchCount = 0;
+ bool foundSelf = false;
+ if (parent != null)
+ {
+ foreach (var child in parent.Children)
+ {
+ if (child.DisplayName == item.DisplayName)
+ {
+ if (child.Equals(item)) { foundSelf = true; break; }
+ matchCount++;
+ }
+ siblingIndex++;
+ }
+ }
+ int index = foundSelf ? matchCount : 0;
+
+ return new AliasNodeIdentity(path, index);
+ }
+}
+```
+
+### 4.3 回溯定位
+
+```csharp
+// DB 中只有 HierarchicalPath + SiblingIndex。
+// 在树中定位时:沿 path 逐层匹配 DisplayName,
+// 到叶子层级时用 SiblingIndex 在同名子节点中定位。
+```
+
+### 4.4 容错
+
+如果路径匹配不到目标节点(模型结构变化),标记为「孤儿别名」:
+- 在别名树中以灰色 + 删除线显示
+- 提供"清除孤儿别名"按钮
+
+---
+
+## 5. 别名数据持久化(PathDatabase 扩展)
+
+### 5.1 新增 SQLite 表
+
+```sql
+CREATE TABLE IF NOT EXISTS AliasMap (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ NodeKey TEXT NOT NULL UNIQUE, -- AliasNodeIdentity.ToKey()
+ AliasText TEXT NOT NULL,
+ CreatedAt TEXT DEFAULT (datetime('now')),
+ ModifiedAt TEXT DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_alias_key ON AliasMap(NodeKey);
+```
+
+### 5.2 AliasDataStore 接口
+
+```csharp
+public class AliasDataStore
+{
+ // 生命周期跟随 PathDatabase(文档打开时连接,关闭时断开)
+
+ string GetAlias(AliasNodeIdentity id); // 读
+ void SetAlias(AliasNodeIdentity id, string alias); // 写(upsert)
+ void DeleteAlias(AliasNodeIdentity id); // 删
+ void DeleteAliasesByPrefix(string pathPrefix); // 批量删(清子节点)
+ Dictionary LoadAll(); // 全量加载到内存缓存
+ int Count { get; } // 统计
+
+ // 孤儿别名
+ List FindOrphans(HashSet validKeys); // 检测
+ void PurgeOrphans(HashSet validKeys); // 清理
+}
+```
+
+### 5.3 与 PathDatabase 的关系
+
+`AliasDataStore` 不持有独立 SQLiteConnection。通过 `PathDatabase.Connection` 操作,使别名表与路径/碰撞数据共享同一事务生命周期和备份机制。
+
+---
+
+## 6. 面板 UI 设计
+
+### 6.1 布局
+
+```
+┌──────────────────────────────────────────┐
+│ 顶部图标工具栏(24px 高) │
+│ [当前: 未命名节点标题] [💾] [⏬] [⏫] [⚙] [🗑] │
+│ 保存 导出 导入 自动 清除 │
+│ │
+│ [别名输入框 ] [✓] │
+├──────────────────────────────────────────┤
+│ TreeView(别名导航树) │
+│ ├─ 模型名(灰色斜体=无别名) │
+│ │ ├─ 一楼 → "1F-大厅" ◆蓝色粗体 │
+│ │ ├─ 一楼 → 灰色斜体 │
+│ │ ├─ 一楼_001 → 灰色斜体 │
+│ │ └─ ... │
+│ └─ 另一个模型 │
+│ └─ ... │
+└──────────────────────────────────────────┘
+```
+
+视觉规范:
+- **有别名**:蓝色 (#1565C0) 粗体,加 ◆ 前缀图标
+- **无别名**:灰色 (#888) 斜体,显示原 DisplayName
+- **编辑中**:`TextBox` 覆盖 TextBlock,背景浅黄
+- **孤儿别名**:灰色删除线
+- **鼠标悬停行**:背景浅蓝 + 显示铅笔按钮 ✎
+- **工具栏**:图标按钮 24×24,Navisworks 蓝色系,鼠标悬停高亮
+
+### 6.2 顶部图标工具栏
+
+按钮统一使用 WPF `Path` 几何图形(与项目 `MediaControlIcons.xaml` 同模式),不依赖外部图片文件。
+工具栏风格参考 Visual Studio / Navisworks 内置面板:紧凑横向排列,仅图标,悬停显示 ToolTip。
+
+| 图标 | Geometry(Path Data) | ToolTip | 功能 |
+|------|----------------------|---------|------|
+| 💾 保存 | `M15,9H5V5H15M15,19H5V15H15M17,3H3V21H17V16L21,12L17,8V3Z` | 保存别名 | 保存顶部输入框中的别名到当前选中节点 |
+| ⏬ 导出 | `M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z` | 导出别名 (JSON) | 导出全量别名到 `.alias.json` |
+| ⏫ 导入 | `M5,10H19V12H5M12,5L5,12H9V21H15V12H19L12,5Z` | 导入别名 (JSON) | 从 `.alias.json` 导入(合并模式) |
+| ⚙ 自动 | `M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.97 19.05,5.05L16.56,6.05C16.04,5.65 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.65 7.44,6.05L4.95,5.05C4.73,4.97 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.95C7.96,18.35 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.35 16.56,17.95L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z` | 自动生成 | 按父链路径自动生成前缀别名 |
+| 🗑 清除 | `M6,19V7H18V19H6M9,4H15L14.5,3H9.5L9,4M19,5V7H5V5H8L8.5,4H15.5L16,5H19Z` | 清除全部别名 | 清除所有别名和高亮,恢复原名显示 |
+
+第二行保留文本输入区:
+- `「当前: xxx」` — 选中节点的 DisplayName
+- 别名输入框 — 直接输入别名
+- `✓` 确认按钮 — 与回车等效,保存并刷新
+
+### 6.3 图标按钮实现
+
+沿用项目现有 `MediaControlIcons.xaml` 的模式 — 纯 WPF `Path` 几何图形,不依赖外部 .ico/.png 文件。
+
+图标资源文件 `AliasTreeIcons.xaml`:
+
+```xml
+
+
+
+
+ M15,9H5V5H15M15,19H5V15H15M17,3H3V21H17V16L21,12L17,8V3Z
+
+
+
+
+ M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z
+
+
+
+
+ M5,10H19V12H5M12,5L5,12H9V21H15V12H19L12,5Z
+
+
+
+
+ M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12
+ A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 ...
+
+
+
+
+ M6,19V7H18V19H6M9,4H15L14.5,3H9.5L9,4M19,5V7H5V5H8L8.5,4H15.5L16,5H19Z
+
+
+
+
+
+
+```
+
+XAML 中的使用方式(参考 `MediaControlIcons.xaml` 模式):
+
+```xml
+
+```
+
+合并到控件 XAML 的 `ResourceDictionary.MergedDictionaries`:
+
+```xml
+
+
+
+
+```
+
+### 6.4 右键菜单
+
+| 菜单项 | 快捷键 | 功能 |
+|--------|--------|------|
+| 编辑别名 | F2 | 就地编辑 TextBox |
+| 清除别名 | Del | 删除别名,恢复显示原名 |
+| **批量编辑子节点…** | — | 弹出批量编辑对话框(见第 7 节) |
+| 在视图中聚焦 | — | 3D 视图中选中 + 聚焦该节点 |
+| 复制原名 | — | 复制 DisplayName 到剪贴板 |
+
+---
+
+## 7. 批量编辑子节点(新增功能)
+
+### 7.1 场景
+
+Navisworks 中常见:一个父节点下 20 个子节点全叫「楼层」或「管道」,需要统一加前缀。
+
+### 7.2 批量编辑对话框
+
+```
+┌──────────────────────────────────┐
+│ 批量编辑子节点 — 父节点: "办公楼层" │
+│ │
+│ 编辑模式: │
+│ ○ 统一前缀 [2F- ] │
+│ ● 统一后缀 [ _办公区] │
+│ ○ 替换原名 [自定义名称 ] │
+│ ○ 序号模板 [管道_{0:D2} ] │ ← 自动编号
+│ │
+│ 预览: │
+│ 楼层 → 楼层_办公区 │
+│ 楼层 → 楼层_001_办公区 │ ← 同名自动去重
+│ 走廊 → 走廊_办公区 │
+│ 电梯间 → 电梯间_办公区 │
+│ ...共 12 个子节点 │
+│ │
+│ [应用] [取消] │
+└──────────────────────────────────┘
+```
+
+### 7.3 四种编辑模式
+
+| 模式 | 输入 | 结果 |
+|------|------|------|
+| 统一前缀 | "2F-" | `2F-原名` |
+| 统一后缀 | "_办公区" | `原名_办公区` |
+| 替换原名 | "自定义名称" | 所有子节点用同一名称 + 去重后缀 `_001` |
+| 序号模板 | "管道_{0:D2}" | `管道_01`, `管道_02`... |
+
+### 7.4 去重保证
+
+`MakeUnique()` 逻辑与 `AliasGenerator` 复用。预览中提前展示最终名称(含去重后缀)。
+
+---
+
+## 8. 别名节点 3D 高亮
+
+### 8.1 触发时机
+
+- 给节点设置别名时,自动在该节点上加永久颜色覆盖
+- 清除别名时,同时清除颜色覆盖
+
+### 8.2 颜色方案
+
+```csharp
+// 别名高亮色:半透明青色
+static readonly Color AliasHighlightColor = Color.FromArgb(80, 0, 150, 200);
+```
+
+### 8.3 实现
+
+```csharp
+public void ApplyAliasHighlight(ModelItem item)
+{
+ var items = new ModelItemCollection { item };
+ var doc = Application.ActiveDocument;
+ doc.Models.OverridePermanentColor(items, AliasHighlightColor);
+}
+
+public void RemoveAliasHighlight(ModelItem item)
+{
+ var items = new ModelItemCollection { item };
+ var doc = Application.ActiveDocument;
+ doc.Models.OverridePermanentColor(items, Color.Empty); // 恢复默认
+}
+```
+
+### 8.4 清理
+
+- 文档关闭时自然清理(颜色覆盖不持久化到 NWD)
+- 清除别名时同时清除颜色
+- 全部别名清除提供「清除所有别名高亮」操作
+
+### 8.5 与其他高亮的兼容
+
+项目已有 `ModelHighlightHelper`,使用 category 机制管理高亮。别名高亮用专用 category `"AliasHighlight"`:
+
+```csharp
+ModelHighlightHelper.HighlightItems("AliasHighlight", itemCollection);
+```
+
+可与物流分类高亮、碰撞高亮共存,互不覆盖。
+
+---
+
+## 9. JSON 导入/导出
+
+### 9.1 格式
+
+```json
+{
+ "version": 1,
+ "exportedAt": "2026-05-31T10:30:00",
+ "documentName": "Project.nwd",
+ "aliases": [
+ {
+ "path": "模型名/一楼/楼层",
+ "siblingIndex": 0,
+ "alias": "1F-办公层"
+ },
+ {
+ "path": "模型名/一楼/楼层",
+ "siblingIndex": 1,
+ "alias": "1F-大堂"
+ }
+ ]
+}
+```
+
+### 9.2 导入策略
+
+- **合并模式**:JSON 中的别名覆盖现有同名 key;JSON 中没有的不动。
+
+### 9.3 字段说明
+
+| 字段 | 用途 |
+|------|------|
+| `path` | 父链 DisplayName 拼接(对应 DB 的 HierarchicalPath) |
+| `siblingIndex` | 同级去重索引 |
+| `alias` | 用户别名 |
+
+---
+
+## 10. 双向联动机制
+
+### 10.1 点击别名树 → 选中模型
+
+```csharp
+private void OnAliasTreeSelected(AliasNodeViewModel node)
+{
+ if (node.ModelItem == null) return;
+
+ _isInternalSelection = true; // 防止循环触发
+ var coll = new ModelItemCollection { node.ModelItem };
+ Application.ActiveDocument.CurrentSelection.SelectedItems = coll;
+ _isInternalSelection = false;
+}
+```
+
+### 10.2 Navisworks 选中变化 → 别名树高亮
+
+```csharp
+private void OnNavisSelectionChanged(object sender, EventArgs e)
+{
+ if (_isInternalSelection) return; // 防止循环
+
+ Dispatcher.BeginInvoke(() =>
+ {
+ var selected = Application.ActiveDocument.CurrentSelection.SelectedItems;
+ if (selected.Count == 1)
+ {
+ var item = selected.First();
+ TbCurrentName.Text = item.DisplayName;
+ ExpandAndHighlight(item);
+ }
+ });
+}
+```
+
+### 10.3 展开路径
+
+```csharp
+private void ExpandAndHighlight(ModelItem item)
+{
+ var identity = AliasNodeIdentity.FromModelItem(item);
+ // 1. 逐层沿 path 找 TreeViewItem,展开 + 延时加载
+ // 2. 到目标层用 SiblingIndex 定位
+ // 3. IsSelected = true + BringIntoView()
+}
+```
+
+---
+
+## 11. 线程安全
+
+**所有 Navisworks API 调用必须在主 STA 线程**(项目第 8 节硬约束)。
+
+| 操作 | 线程 |
+|------|------|
+| 遍历 ModelItem(构建树/生成别名) | 主线程(必须) |
+| `Application.ActiveDocument` 访问 | 主线程(必须) |
+| `OverridePermanentColor` | 主线程(必须) |
+| `CurrentSelection.SelectedItems` | 主线程(必须) |
+| 读/写 SQLite(AliasDataStore) | 可通过 `Task.Run` 在后台 + 锁保护 |
+| JSON 解析/序列化 | 后台线程安全 |
+| `Dispatcher.BeginInvoke` 延迟 UI 更新 | 主线程回调 |
+
+大模型优化:
+- 树构建分批次:前 200 个根节点立即构建,后续通过 `Dispatcher.BeginInvoke(DispatcherPriority.Background, ...)` 分批追加
+- 首屏只加载 Viewport 内可见项(`VirtualizingPanel` 已覆盖)
+- 展开时才真正递归子节点(占位符机制)
+
+---
+
+## 12. 文件组织与集成
+
+### 12.1 目录结构
+
+```
+src/
+├── Core/
+│ └── AliasTree/
+│ ├── AliasTreePlugin.cs # DockPanePlugin
+│ ├── AliasNodeIdentity.cs # 复合节点标识
+│ └── AliasDataStore.cs # CRUD + SQLite
+├── UI/
+│ └── WPF/
+│ ├── Views/
+│ │ ├── AliasTreeControl.xaml
+│ │ ├── AliasTreeControl.xaml.cs
+│ │ ├── BatchAliasDialog.xaml
+│ │ └── BatchAliasDialog.xaml.cs
+│ └── ViewModels/
+│ └── AliasNodeViewModel.cs # 单节点 VM
+```
+
+### 12.2 命名空间
+
+- `NavisworksTransport.Core.AliasTree` — 插件入口、数据层
+- `NavisworksTransport.UI.WPF.Views` — XAML 视图
+- `NavisworksTransport.UI.WPF.ViewModels` — ViewModel
+
+### 12.3 插件注册
+
+```csharp
+// src/Core/AliasTree/AliasTreePlugin.cs
+[Plugin("NavisworksTransport.AliasTree", "Tian",
+ DisplayName = "别名导航树",
+ ToolTip = "自定义节点别名管理面板")]
+[DockPanePlugin(300, 500, FixedSize = false)]
+public class AliasTreePlugin : DockPanePlugin
+{
+ public override Control CreateControlPane()
+ {
+ ElementHost eh = new ElementHost { AutoSize = true };
+ eh.Child = new AliasTreeControl();
+ eh.CreateControl();
+ return eh;
+ }
+
+ public override void DestroyControlPane(Control pane)
+ {
+ if (pane is ElementHost eh && eh.Child is AliasTreeControl ctrl)
+ ctrl.Cleanup();
+ pane?.Dispose();
+ }
+}
+```
+
+### 12.4 用户操作步骤
+
+1. 启动 Navisworks → 打开模型
+2. **View → Docking Windows → 别名导航树** 打开面板
+3. 手动拖动面板,dock 到内置 Selection Tree 下方
+4. 选中节点后,在顶部输入框或内联编辑打别名
+5. 别名自动持久化到 `DocName.db`,下次打开模型自动恢复
+
+---
+
+## 13. 实施步骤(按优先级)
+
+### 阶段 1:数据层(2 天)
+
+1. ✅ `AliasNodeIdentity` — 复合标识 + 测试
+2. ✅ `AliasDataStore` — CRUD + 集成到 PathDatabase.Connection
+3. ✅ PathDatabase 新增 `AliasMap` 表 + 迁移逻辑
+4. ✅ 单元测试:identity 生成、去重、回溯定位
+
+### 阶段 2:VM + TreeView 骨架(2 天)
+
+5. ✅ `AliasNodeViewModel` — INotifyPropertyChanged
+6. ✅ `AliasTreeControl.xaml` — TreeView + 工具栏布局
+7. ✅ 延迟加载(占位节点 + Expanded 事件)
+8. ✅ 顶部工具栏(当前名 + 输入框 + 保存)
+
+### 阶段 3:编辑功能(1.5 天)
+
+9. ✅ 内联编辑(文本框 + 铅笔按钮)
+10. ✅ 右键菜单(编辑/清除/聚焦/复制原名)
+11. ✅ `BatchAliasDialog` — 四种批量模式
+12. ✅ `AliasGenerator` — 前缀生成(复用参考方案逻辑)
+
+### 阶段 4:联动 + 高亮(1.5 天)
+
+13. ✅ 点击别名树 → 选中模型(防循环)
+14. ✅ 选中模型 → 展开别名树并高亮
+15. ✅ 别名节点 3D 颜色高亮 + 清别名时清除
+
+### 阶段 5:导入导出 + 清理(1 天)
+
+16. ✅ JSON 导出
+17. ✅ JSON 导入(合并模式)
+18. ✅ 孤儿别名检测 + 清理
+19. ✅ Cleanup 方法
+
+### 阶段 6:集成测试(1 天)
+
+20. ✅ 大模型性能测试(10 万+ 节点)
+21. ✅ 文档切换生命周期测试
+22. ✅ 同名字兄弟节点去重测试
+23. ✅ 回归:不影响现有物流分类/碰撞检测
+
+---
+
+## 14. 风险与已知限制
+
+| 风险 | 缓解 |
+|------|------|
+| 父链路径在模型结构变化后断裂 | 孤儿别名标记 + 清理功能 |
+| 同名兄弟节点计数漂移 | 同级插入/删除后索引可能偏移,需提供「刷新别名索引」 |
+| `Children.Any()` 触发集合枚举 | 用 `Children.Count > 0` 代替,或从 COM API 取 path 来判空 |
+| 大模型首次加载 TreeView 卡顿 | 分批构建 + Dispatcher.Background + 虚拟化 |
+| Navisworks 不支持编程控制 dock 位置 | 首次使用文档中明确引导用户手动拖动面板 |
+
+---
+
+## 15. 与现有功能的隔离
+
+- 不修改任何现有 `PathPlanningManager` / `PathDatabase` 核心逻辑(只在 DB 中加表)
+- 不侵入 `LogisticsControlPanel`(独立 `DockPanePlugin`)
+- 别名高亮使用独立 `ModelHighlightHelper` category,不干扰物流分类高亮
+- `AliasDataStore` 只读 `PathDatabase.Connection`,不持有自己的连接
diff --git a/doc/design/2026/节点重命名参考方案.md b/doc/design/2026/节点重命名参考方案.md
new file mode 100644
index 0000000..78745ce
--- /dev/null
+++ b/doc/design/2026/节点重命名参考方案.md
@@ -0,0 +1,724 @@
+原生树无法扩展,自定义别名树面板就是唯一可靠的载体。在那个面板里,你可以完全自由地定制右键菜单,比如:
+表格 菜单项 功能
+编辑别名 就地改名
+清除别名 恢复原名
+在视图中聚焦 3D 定位
+批量设置前缀 对当前节点及其子节点统一加前缀
+导出此分支 只导出当前节点下的别名 CSV
+
+实际工作流画面
+想象你的屏幕布局:
+plain
+┌─────────────────┬──────────────────┐
+│ 内置 Selection │ 3D 模型视图 │
+│ Tree │ │
+│ (原始名字) │ [框选操作区] │
+│ │ │
+├─────────────────┤ │
+│ 别名导航树 │ │
+│ (自定义名字) │ │
+│ │ │
+└─────────────────┴──────────────────┘
+找东西:用户在别名树里看到"二楼-空调水管-123",一眼认出。
+点一下:别名树高亮,3D 视图聚焦到那根管子,内置 Selection Tree 也同步跳转到对应节点。
+反过来:用户在 3D 视图里框选一根管子,内置树亮了,别名树也自动展开高亮显示"二楼-空调水管-123"。
+用户完全不需要记住原始命名规则,两个面板互相印证。
+一个小提醒:首次加载性能
+如果模型有 10万+ 节点,且你需要一次性把所有别名加载进字典,建议在插件初始化时做一个后台线程 + 进度条:
+csharp
+// 异步构建别名字典,避免 UI 卡死
+private async void BuildAliasMapAsync()
+{
+ await Task.Run(() =>
+ {
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ // 遍历所有节点生成 Guid->Alias 映射
+ // ...
+ });
+ RefreshTree();
+}
+结论
+这个"双面板联动"方案是性价比最高的落地方式:
+没有破坏 Navisworks 原生结构(稳定、升级兼容)
+没有重写复杂树控件(100 多行代码搞定)
+** alias 数据完全由你掌控**(Excel、数据库、规则生成均可)
+用户体验几乎等同于原生支持别名
+
+下面是可直接编译的完整方案,共 6 个文件。核心思路:
+AliasTreePane:Navisworks DockPane 外壳。
+AliasTreeControl:WPF 别名树面板,支持悬停铅笔编辑、顶部快捷栏、右键菜单、CSV 导入导出、自动生成前缀别名。
+双向联动:点击别名树选中模型;在 3D 视图/内置树选中后,别名树自动展开并高亮。
+延迟加载:大模型下只加载可见节点,展开时才填充子节点。
+
+1. AliasNode.cs(数据模型)
+
+using Autodesk.Navisworks.Api;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+
+namespace AliasTree
+{
+ public class AliasNode : INotifyPropertyChanged
+ {
+ public ModelItem ModelItem { get; set; }
+ public Guid Guid => ModelItem?.InstanceGuid ?? Guid.Empty;
+
+ private string _alias;
+ public string Alias
+ {
+ get => _alias;
+ set
+ {
+ _alias = value;
+ OnPropertyChanged(nameof(Alias));
+ OnPropertyChanged(nameof(DisplayText));
+ }
+ }
+
+ private bool _isEditing;
+ public bool IsEditing
+ {
+ get => _isEditing;
+ set { _isEditing = value; OnPropertyChanged(nameof(IsEditing)); }
+ }
+
+ public string DisplayText => string.IsNullOrEmpty(Alias) ? (ModelItem?.DisplayName ?? "未命名") : Alias;
+
+ public List< Children { get; set; } = new List<();
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ protected void OnPropertyChanged(string name) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+}
+
+2. AliasTreePlugin.cs(插件入口)
+
+using Autodesk.Navisworks.Api.Plugins;
+
+namespace AliasTree
+{
+ [Plugin("AliasTreePane", "YourCompany", DisplayName = "别名导航树")]
+ [DockPanePlugin(400, 600, FixedSize = false)]
+ public class AliasTreePlugin : DockPanePlugin
+ {
+ public override Pane CreatePane() => new AliasTreePane();
+ }
+}
+
+3. AliasTreePane.cs(Pane 外壳)
+
+using Autodesk.Navisworks.Api.Plugins;
+
+namespace AliasTree
+{
+ public class AliasTreePane : Pane
+ {
+ public AliasTreePane()
+ {
+ this.Content = new AliasTreeControl();
+ }
+ }
+}
+
+4. AliasTreeControl.xaml(界面布局)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+5. AliasTreeControl.xaml.cs(核心逻辑)
+
+using Autodesk.Navisworks.Api;
+using Microsoft.Win32;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace AliasTree
+{
+ public partial class AliasTreeControl : UserControl
+ {
+ private Dictionary< _aliasMap = new Dictionary<();
+ private List< _rootNodes = new List<();
+
+ public AliasTreeControl()
+ {
+ InitializeComponent();
+ AliasTreeView.ItemsSource = _rootNodes;
+
+ Autodesk.Navisworks.Api.Application.MainDocumentChanged += (s, e) =>
+ {
+ HookSelection();
+ RefreshTree();
+ };
+
+ HookSelection();
+ RefreshTree();
+ }
+
+ private void HookSelection()
+ {
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc != null)
+ doc.CurrentSelection.Changed += OnNavisSelectionChanged;
+ }
+
+ // ==================== 树构建与延迟加载 ====================
+ private void RefreshTree()
+ {
+ _rootNodes.Clear();
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc == null) return;
+
+ foreach (var root in doc.Models.RootItems)
+ {
+ var node = CreateAliasNode(root, true);
+ if (node != null) _rootNodes.Add(node);
+ }
+ }
+
+ private AliasNode CreateAliasNode(ModelItem item, bool lazyLoad)
+ {
+ if (item == null) return null;
+
+ var node = new AliasNode
+ {
+ ModelItem = item,
+ Alias = GetAlias(item.InstanceGuid)
+ };
+
+ if (item.Children.Any())
+ {
+ if (lazyLoad)
+ {
+ // 添加占位节点,使 TreeView 显示展开箭头
+ node.Children.Add(new AliasNode { ModelItem = null, Alias = "Loading..." });
+ }
+ else
+ {
+ foreach (var child in item.Children)
+ {
+ var childNode = CreateAliasNode(child, true);
+ if (childNode != null) node.Children.Add(childNode);
+ }
+ }
+ }
+ return node;
+ }
+
+ private void TreeViewItem_Expanded(object sender, RoutedEventArgs e)
+ {
+ if (e.OriginalSource is TreeViewItem tvi && tvi.DataContext is AliasNode node)
+ {
+ // 检查是否是占位节点(尚未加载真实子节点)
+ if (node.Children.Count == 1 && node.Children[0].ModelItem == null)
+ {
+ node.Children.Clear();
+ if (node.ModelItem != null)
+ {
+ foreach (var child in node.ModelItem.Children)
+ {
+ var childNode = CreateAliasNode(child, true);
+ if (childNode != null) node.Children.Add(childNode);
+ }
+ }
+ }
+ }
+ }
+
+ // ==================== 双向联动 ====================
+ private void TreeViewItem_Selected(object sender, RoutedEventArgs e)
+ {
+ if (e.OriginalSource is TreeViewItem tvi && tvi.DataContext is AliasNode node)
+ {
+ if (node.ModelItem == null) return;
+
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc == null) return;
+
+ var coll = new ModelItemCollection();
+ coll.Add(node.ModelItem);
+ doc.CurrentSelection.SelectedItems = coll;
+ }
+ }
+
+ private void OnNavisSelectionChanged(object sender, EventArgs e)
+ {
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc == null) return;
+
+ var selected = doc.CurrentSelection.SelectedItems;
+
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (selected.Count == 1)
+ {
+ var item = selected.First();
+ TbCurrentName.Text = item.DisplayName;
+ TbCurrentName.Tag = item;
+ ExpandAndHighlight(item.InstanceGuid);
+ }
+ else
+ {
+ TbCurrentName.Text = selected.Count > 1 ? $"({selected.Count} 个选中)" : "无";
+ TbCurrentName.Tag = null;
+ }
+ }));
+ }
+
+ private void ExpandAndHighlight(Guid guid)
+ {
+ // 找到路径
+ var path = new List<();
+ if (!FindPath(_rootNodes, guid, path)) return;
+
+ // 逐层展开并加载数据
+ ItemsControl current = AliasTreeView;
+ foreach (var node in path)
+ {
+ // 强制加载延迟加载的节点数据
+ if (node.Children.Count == 1 && node.Children[0].ModelItem == null)
+ {
+ node.Children.Clear();
+ if (node.ModelItem != null)
+ {
+ foreach (var child in node.ModelItem.Children)
+ {
+ var childNode = CreateAliasNode(child, true);
+ if (childNode != null) node.Children.Add(childNode);
+ }
+ }
+ }
+
+ if (current is TreeViewItem tvi)
+ {
+ tvi.IsExpanded = true;
+ tvi.UpdateLayout();
+ }
+
+ current = (TreeViewItem)current.ItemContainerGenerator.ContainerFromItem(node);
+ if (current == null) break; // 虚拟化下可能未生成,中断
+ }
+
+ // 高亮目标
+ if (current is TreeViewItem target && target.DataContext is AliasNode targetNode && targetNode.Guid == guid)
+ {
+ target.IsSelected = true;
+ target.BringIntoView();
+ }
+ }
+
+ private bool FindPath(List< nodes, Guid guid, List< path)
+ {
+ foreach (var n in nodes)
+ {
+ path.Add(n);
+ if (n.Guid == guid) return true;
+ if (FindPath(n.Children, guid, path)) return true;
+ path.RemoveAt(path.Count - 1);
+ }
+ return false;
+ }
+
+ // ==================== 内联编辑(铅笔图标) ====================
+ private void BtnEdit_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as Button)?.Tag is not AliasNode node) return;
+ ClearAllEditing(_rootNodes);
+ node.IsEditing = true;
+
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var container = AliasTreeView.ItemContainerGenerator.ContainerFromItem(node) as TreeViewItem;
+ if (container == null) return;
+ var txt = FindVisualChild(container);
+ if (txt != null)
+ {
+ txt.Focus();
+ txt.SelectAll();
+ }
+ }), System.Windows.Threading.DispatcherPriority.Render);
+ }
+
+ private void AliasTextBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ (sender as TextBox)?.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
+ }
+
+ private void AliasTextBox_LostFocus(object sender, RoutedEventArgs e)
+ {
+ if ((sender as TextBox)?.DataContext is not AliasNode node) return;
+ SaveAlias(node.Guid, node.Alias);
+ node.IsEditing = false;
+ node.Alias = GetAlias(node.Guid); // 同步最终值
+ }
+
+ // ==================== 右键菜单 ====================
+ private void CtxEdit_Click(object sender, RoutedEventArgs e)
+ {
+ if (AliasTreeView.SelectedItem is AliasNode node)
+ BtnEdit_Click(new Button { Tag = node }, null);
+ }
+
+ private void CtxClear_Click(object sender, RoutedEventArgs e)
+ {
+ if (AliasTreeView.SelectedItem is not AliasNode node) return;
+ SaveAlias(node.Guid, null);
+ node.Alias = null;
+ }
+
+ private void CtxFocus_Click(object sender, RoutedEventArgs e)
+ {
+ if (AliasTreeView.SelectedItem is not AliasNode node || node.ModelItem == null) return;
+
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc == null) return;
+
+ var coll = new ModelItemCollection();
+ coll.Add(node.ModelItem);
+ doc.CurrentSelection.SelectedItems = coll;
+ doc.ActiveView.FocusOnItem(node.ModelItem, false);
+ }
+
+ // ==================== 顶部快捷栏 ====================
+ private void TxtQuickAlias_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter) BtnQuickSave_Click(null, null);
+ }
+
+ private void BtnQuickSave_Click(object sender, RoutedEventArgs e)
+ {
+ if (TbCurrentName.Tag is not ModelItem item) return;
+ if (string.IsNullOrWhiteSpace(TxtQuickAlias.Text)) return;
+
+ SaveAlias(item.InstanceGuid, TxtQuickAlias.Text.Trim());
+ TxtQuickAlias.Clear();
+ RefreshNodeDisplay(item.InstanceGuid);
+ }
+
+ // ==================== 自动生成别名 ====================
+ private void BtnAutoGen_Click(object sender, RoutedEventArgs e)
+ {
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc == null) return;
+
+ var gen = new AliasGenerator();
+ var generated = gen.Generate(doc.Models.RootItems.FirstOrDefault(), "/");
+
+ foreach (var kv in generated)
+ _aliasMap[kv.Key] = kv.Value;
+
+ RefreshTree();
+ }
+
+ // ==================== CSV 导入导出 ====================
+ private void BtnExport_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new SaveFileDialog { Filter = "CSV 文件|*.csv", Title = "导出别名映射" };
+ if (dlg.ShowDialog() != true) return;
+
+ var lines = new List { "Guid,OriginalName,Alias" };
+ foreach (var kv in _aliasMap)
+ {
+ var item = Autodesk.Navisworks.Api.Application.ActiveDocument?.Models.FindItemByGuid(kv.Key);
+ string orig = item?.DisplayName ?? "";
+ lines.Add($"{kv.Key},\"{orig}\",\"{kv.Value}\"");
+ }
+ File.WriteAllLines(dlg.FileName, lines);
+ MessageBox.Show($"已导出 {_aliasMap.Count} 条别名记录。", "导出完成",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void BtnImport_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFileDialog { Filter = "CSV 文件|*.csv", Title = "导入别名映射" };
+ if (dlg.ShowDialog() != true) return;
+
+ int count = 0;
+ foreach (var line in File.ReadLines(dlg.FileName).Skip(1))
+ {
+ var parts = ParseCsvLine(line);
+ if (parts.Length >= 3 && Guid.TryParse(parts[0], out var guid))
+ {
+ string alias = parts[2].Trim('"');
+ if (!string.IsNullOrWhiteSpace(alias))
+ {
+ _aliasMap[guid] = alias;
+ count++;
+ }
+ }
+ }
+ RefreshTree();
+ MessageBox.Show($"已导入 {count} 条别名记录。", "导入完成",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ // ==================== 辅助方法 ====================
+ private string GetAlias(Guid guid) =>
+ _aliasMap.ContainsKey(guid) ? _aliasMap[guid] : null;
+
+ private void SaveAlias(Guid guid, string alias)
+ {
+ if (string.IsNullOrWhiteSpace(alias))
+ {
+ if (_aliasMap.ContainsKey(guid)) _aliasMap.Remove(guid);
+ }
+ else
+ {
+ _aliasMap[guid] = alias;
+ }
+ }
+
+ private void RefreshNodeDisplay(Guid guid)
+ {
+ var node = FindNode(_rootNodes, guid);
+ if (node != null) node.Alias = GetAlias(guid);
+ }
+
+ private AliasNode FindNode(List< nodes, Guid guid)
+ {
+ foreach (var n in nodes)
+ {
+ if (n.Guid == guid) return n;
+ var found = FindNode(n.Children, guid);
+ if (found != null) return found;
+ }
+ return null;
+ }
+
+ private void ClearAllEditing(List< nodes)
+ {
+ foreach (var n in nodes)
+ {
+ n.IsEditing = false;
+ ClearAllEditing(n.Children);
+ }
+ }
+
+ private static T FindVisualChild(DependencyObject parent) where T : DependencyObject
+ {
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (child is T t) return t;
+ var result = FindVisualChild(child);
+ if (result != null) return result;
+ }
+ return null;
+ }
+
+ private static string[] ParseCsvLine(string line)
+ {
+ var result = new List();
+ bool inQuotes = false;
+ var sb = new System.Text.StringBuilder();
+ foreach (char c in line)
+ {
+ if (c == '"')
+ {
+ inQuotes = !inQuotes;
+ }
+ else if (c == ',' && !inQuotes)
+ {
+ result.Add(sb.ToString());
+ sb.Clear();
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+ result.Add(sb.ToString());
+ return result.ToArray();
+ }
+ }
+}
+
+6. AliasGenerator.cs(批量自动生成别名)
+
+using Autodesk.Navisworks.Api;
+using System;
+using System.Collections.Generic;
+
+namespace AliasTree
+{
+ public class AliasGenerator
+ {
+ private Dictionary< _result = new Dictionary<();
+ private HashSet _usedNames = new HashSet();
+
+ public Dictionary< Generate(ModelItem root, string separator = "/")
+ {
+ _result.Clear();
+ _usedNames.Clear();
+ if (root != null)
+ Traverse(root, "", separator, 0);
+ return _result;
+ }
+
+ private void Traverse(ModelItem item, string parentPath, string sep, int depth)
+ {
+ string rawName = item.DisplayName?.Trim() ?? "未命名";
+
+ // 一级节点保持原名;二级开始加父前缀
+ string alias = depth == 0
+ ? rawName
+ : (string.IsNullOrEmpty(parentPath) ? rawName : $"{parentPath}{sep}{rawName}");
+
+ string uniqueAlias = MakeUnique(alias);
+ _result[item.InstanceGuid] = uniqueAlias;
+
+ // 下一级前缀:最多保留最近一级父名,避免路径无限膨胀
+ string nextPrefix = depth < 1 ? uniqueAlias : rawName;
+
+ foreach (var child in item.Children)
+ Traverse(child, nextPrefix, sep, depth + 1);
+ }
+
+ private string MakeUnique(string name)
+ {
+ if (!_usedNames.Contains(name))
+ {
+ _usedNames.Add(name);
+ return name;
+ }
+
+ int idx = 1;
+ string candidate;
+ do
+ {
+ candidate = $"{name}_{idx:D3}";
+ idx++;
+ } while (_usedNames.Contains(candidate));
+
+ _usedNames.Add(candidate);
+ return candidate;
+ }
+ }
+}
+
+编译与部署说明
+
+| 项目 | 内容 |
+| ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| **项目类型** | Visual Studio → C# → 类库(.NET Framework) |
+| **目标框架** | .NET Framework 4.8(与 Navisworks 版本匹配) |
+| **引用 DLL** | `Autodesk.Navisworks.Api.dll`
`Autodesk.Navisworks.Automation.dll`
位置通常在:`C:\Program Files\Autodesk\Navisworks Manage 2024\` |
+| **输出路径** | 编译后,将生成的 DLL 与 XAML/BAML 一起放入:
`%APPDATA%\Autodesk Navisworks Manage 2024\Plugins\AliasTree\` |
+| **启动** | 重启 Navisworks,在 **View → Docking Windows** 中找到 **别名导航树** |
+
+使用流程(假设一个"八个楼层"的模型节点)
+打开插件面板,dock 到内置 Selection Tree 旁边。
+在内置树或 3D 视图中点选一个名叫"楼层"的节点。
+别名树自动高亮该节点,顶部工具栏显示原名。
+任选一种方式打别名:
+鼠标悬停别名树节点 → 点 ✎ 铅笔 → 就地输入"8F-办公层" → 回车。
+看 3D 视图确认位置 → 在顶部输入框敲"8F-办公层" → 点 ✓ 保存。
+右键节点 → 编辑别名。
+如果八个子节点全叫"楼层",可点 自动生成 按钮,一键生成 楼层/楼层_001 … 楼层/楼层_007,你再精修成 1F大堂、2F办公 等。
+工作完成后 导出 CSV,下次打开 Navisworks 时 导入 CSV 即可恢复所有别名。
\ No newline at end of file
diff --git a/src/Core/AliasTree/AliasDataStore.cs b/src/Core/AliasTree/AliasDataStore.cs
new file mode 100644
index 0000000..8877424
--- /dev/null
+++ b/src/Core/AliasTree/AliasDataStore.cs
@@ -0,0 +1,260 @@
+using NavisworksTransport.Utils;
+using System;
+using System.Collections.Generic;
+using System.Data.SQLite;
+using System.Linq;
+
+namespace NavisworksTransport.Core.AliasTree
+{
+ ///
+ /// 别名数据存储 — CRUD 操作 + 与 PathDatabase 对接。
+ ///
+ /// 不持有独立 SQLiteConnection,通过 PathDatabase.Connection 操作,
+ /// 使别名表与路径/碰撞数据共享同一事务生命周期和备份机制。
+ ///
+ /// 线程安全:
+ /// - 构造/打开/关闭在主线程(PathDatabase 生命周期同步)
+ /// - 后台线程读操作通过 Connection 上的锁保护
+ ///
+ public class AliasDataStore
+ {
+ private readonly SQLiteConnection _connection;
+ private readonly object _lock = new object();
+
+ ///
+ /// 构造 AliasDataStore,使用 PathDatabase 的已有连接
+ ///
+ public AliasDataStore(SQLiteConnection connection)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ }
+
+ ///
+ /// 确保 AliasMap 表存在(在 PathDatabase.CreateTables 中已自动创建,
+ /// 这是提供给独立初始化场景的保障)
+ ///
+ public void EnsureTableExists()
+ {
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand(@"
+ CREATE TABLE IF NOT EXISTS AliasMap (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ NodeKey TEXT NOT NULL UNIQUE,
+ AliasText TEXT NOT NULL,
+ CreatedAt TEXT DEFAULT (datetime('now')),
+ ModifiedAt TEXT DEFAULT (datetime('now'))
+ )
+ ", _connection))
+ {
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = new SQLiteCommand(
+ "CREATE INDEX IF NOT EXISTS idx_alias_key ON AliasMap(NodeKey)", _connection))
+ {
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+
+ // ── 单条操作 ──
+
+ ///
+ /// 读取别名。返回 null 表示无别名。
+ ///
+ public string GetAlias(AliasNodeIdentity id)
+ {
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand(
+ "SELECT AliasText FROM AliasMap WHERE NodeKey = @key", _connection))
+ {
+ cmd.Parameters.AddWithValue("@key", id.ToKey());
+ var result = cmd.ExecuteScalar();
+ return result as string;
+ }
+ }
+ }
+
+ ///
+ /// 写入别名(Upsert 语义 — 存在则更新,不存在则插入)
+ ///
+ public void SetAlias(AliasNodeIdentity id, string alias)
+ {
+ if (string.IsNullOrWhiteSpace(alias))
+ {
+ DeleteAlias(id);
+ return;
+ }
+
+ string key = id.ToKey();
+
+ lock (_lock)
+ {
+ // 尝试更新
+ using (var cmd = new SQLiteCommand(
+ "UPDATE AliasMap SET AliasText = @alias, ModifiedAt = datetime('now') WHERE NodeKey = @key",
+ _connection))
+ {
+ cmd.Parameters.AddWithValue("@key", key);
+ cmd.Parameters.AddWithValue("@alias", alias.Trim());
+ int affected = cmd.ExecuteNonQuery();
+
+ if (affected == 0)
+ {
+ // 不存在,插入
+ using (var insertCmd = new SQLiteCommand(
+ "INSERT INTO AliasMap (NodeKey, AliasText) VALUES (@key, @alias)",
+ _connection))
+ {
+ insertCmd.Parameters.AddWithValue("@key", key);
+ insertCmd.Parameters.AddWithValue("@alias", alias.Trim());
+ insertCmd.ExecuteNonQuery();
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// 删除别名
+ ///
+ public void DeleteAlias(AliasNodeIdentity id)
+ {
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand(
+ "DELETE FROM AliasMap WHERE NodeKey = @key", _connection))
+ {
+ cmd.Parameters.AddWithValue("@key", id.ToKey());
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+
+ // ── 批量操作 ──
+
+ ///
+ /// 删除指定路径前缀下的所有别名(用于"清除某分支下全部别名")
+ ///
+ public void DeleteAliasesByPathPrefix(string pathPrefix)
+ {
+ if (string.IsNullOrEmpty(pathPrefix)) return;
+
+ // 使用 LIKE 'prefix%' 匹配所有以该前缀开头的路径
+ string escapedPrefix = pathPrefix.Replace("'", "''").Replace("%", "\\%").Replace("_", "\\_");
+ string likePattern = escapedPrefix + "%";
+
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand(
+ "DELETE FROM AliasMap WHERE NodeKey LIKE @pattern ESCAPE '\\'",
+ _connection))
+ {
+ cmd.Parameters.AddWithValue("@pattern", likePattern);
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+
+ ///
+ /// 全量加载所有别名到内存字典。key=NodeKey, value=AliasText
+ ///
+ public Dictionary LoadAll()
+ {
+ var result = new Dictionary();
+
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand(
+ "SELECT NodeKey, AliasText FROM AliasMap", _connection))
+ using (var reader = cmd.ExecuteReader())
+ {
+ while (reader.Read())
+ {
+ string key = reader.GetString(0);
+ string alias = reader.GetString(1);
+ result[key] = alias;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// 当前别名总数
+ ///
+ public int Count
+ {
+ get
+ {
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand(
+ "SELECT COUNT(*) FROM AliasMap", _connection))
+ {
+ return Convert.ToInt32(cmd.ExecuteScalar());
+ }
+ }
+ }
+ }
+
+ ///
+ /// 批量导入(合并模式:覆盖已有 key,新增不存在的 key)
+ ///
+ public void ImportBatch(Dictionary entries)
+ {
+ if (entries == null || entries.Count == 0) return;
+
+ lock (_lock)
+ {
+ using (var tx = _connection.BeginTransaction())
+ {
+ try
+ {
+ using (var upsertCmd = new SQLiteCommand(
+ @"INSERT INTO AliasMap (NodeKey, AliasText) VALUES (@key, @alias)
+ ON CONFLICT(NodeKey) DO UPDATE SET
+ AliasText = excluded.AliasText,
+ ModifiedAt = datetime('now')",
+ _connection))
+ {
+ var keyParam = upsertCmd.Parameters.Add("@key", System.Data.DbType.String);
+ var aliasParam = upsertCmd.Parameters.Add("@alias", System.Data.DbType.String);
+
+ foreach (var kvp in entries)
+ {
+ keyParam.Value = kvp.Key;
+ aliasParam.Value = kvp.Value;
+ upsertCmd.ExecuteNonQuery();
+ }
+ }
+
+ tx.Commit();
+ }
+ catch
+ {
+ tx.Rollback();
+ throw;
+ }
+ }
+ }
+ }
+
+ ///
+ /// 清除所有别名(危险操作)
+ ///
+ public void ClearAll()
+ {
+ lock (_lock)
+ {
+ using (var cmd = new SQLiteCommand("DELETE FROM AliasMap", _connection))
+ {
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+ }
+}
diff --git a/src/Core/AliasTree/AliasNodeIdentity.cs b/src/Core/AliasTree/AliasNodeIdentity.cs
new file mode 100644
index 0000000..244c3f8
--- /dev/null
+++ b/src/Core/AliasTree/AliasNodeIdentity.cs
@@ -0,0 +1,190 @@
+using Autodesk.Navisworks.Api;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace NavisworksTransport.Core.AliasTree
+{
+ ///
+ /// 稳定的复合节点标识 — 替代 InstanceGuid 用于持久化。
+ ///
+ /// 基于父链 DisplayName 拼接 + 同级同名节点去重索引 构成唯一 key。
+ /// 即使 Revit 重新导出 NWC/append 进 NWD,只要 DisplayName 层级结构不变,
+ /// 别名就能正确关联。
+ ///
+ /// 设计原则:
+ /// - 序列化格式: "path/to/node#index",便于 SQLite 存储和 JSON 导出
+ /// - 不可变结构体(值语义),可用作 Dictionary key
+ /// - 从 ModelItem 构造时在主线程调用(Navisworks API)
+ /// - 反序列化/比较时纯字符串操作(可在后台线程安全使用)
+ ///
+ public readonly struct AliasNodeIdentity : IEquatable
+ {
+ /// 父链 DisplayName 拼接路径
+ public string HierarchicalPath { get; }
+
+ /// 同级同名节点中的去重索引(0-based)
+ public int SiblingIndex { get; }
+
+ ///
+ /// 构造复合标识
+ ///
+ public AliasNodeIdentity(string hierarchicalPath, int siblingIndex)
+ {
+ if (string.IsNullOrEmpty(hierarchicalPath))
+ throw new ArgumentException("路径不能为空", nameof(hierarchicalPath));
+ if (siblingIndex < 0)
+ throw new ArgumentOutOfRangeException(nameof(siblingIndex), "索引不能为负");
+
+ HierarchicalPath = hierarchicalPath;
+ SiblingIndex = siblingIndex;
+ }
+
+ ///
+ /// 从 ModelItem 构造复合标识。
+ /// 注意:必须在主线程中调用,因为涉及 Navisworks API 访问。
+ ///
+ public static AliasNodeIdentity FromModelItem(ModelItem item)
+ {
+ if (item == null)
+ throw new ArgumentNullException(nameof(item));
+
+ // 1. 沿 Parent 链收集 DisplayName
+ var parts = new List();
+ var current = item;
+ while (current != null)
+ {
+ parts.Insert(0, current.DisplayName ?? "?");
+ current = current.Parent;
+ }
+
+ var path = string.Join("/", parts);
+
+ // 2. 计算同级同名节点中的索引
+ int siblingIndex = 0;
+ int matchIndex = 0;
+ bool foundSelf = false;
+
+ 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);
+ }
+
+ ///
+ /// 序列化为数据库主键字符串
+ ///
+ public string ToKey()
+ {
+ return $"{HierarchicalPath}#{SiblingIndex}";
+ }
+
+ ///
+ /// 从 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}");
+
+ 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);
+ }
+
+ ///
+ /// 提前验证 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;
+
+ 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;
+ }
+
+ ///
+ /// 仅检查 HierarchicalPath 前缀匹配 — 用于"批量删除该分支下所有别名"
+ ///
+ public bool HasPathPrefix(string prefix)
+ {
+ return HierarchicalPath.StartsWith(prefix, StringComparison.Ordinal);
+ }
+
+ // ── 值语义 ──
+
+ public bool Equals(AliasNodeIdentity other)
+ {
+ return string.Equals(HierarchicalPath, other.HierarchicalPath, StringComparison.Ordinal)
+ && SiblingIndex == other.SiblingIndex;
+ }
+
+ public override bool Equals(object obj)
+ {
+ return obj is AliasNodeIdentity other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ return (HierarchicalPath.GetHashCode() * 397) ^ SiblingIndex;
+ }
+ }
+
+ public static bool operator ==(AliasNodeIdentity left, AliasNodeIdentity right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(AliasNodeIdentity left, AliasNodeIdentity right)
+ {
+ return !left.Equals(right);
+ }
+
+ public override string ToString()
+ {
+ return ToKey();
+ }
+ }
+}
diff --git a/src/Core/PathDatabase.cs b/src/Core/PathDatabase.cs
index cd921c3..c589b8a 100644
--- a/src/Core/PathDatabase.cs
+++ b/src/Core/PathDatabase.cs
@@ -447,8 +447,20 @@ namespace NavisworksTransport
");
SafeCreateIndex("CREATE INDEX IF NOT EXISTS idx_detection_manual_record ON CollisionDetectionManualTargets(DetectionRecordId)", "CollisionDetectionManualTargets", "DetectionRecordId");
+ // 别名映射表(节点重命名 — 别名导航树)
+ ExecuteNonQuery(@"
+ CREATE TABLE IF NOT EXISTS AliasMap (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ NodeKey TEXT NOT NULL UNIQUE,
+ AliasText TEXT NOT NULL,
+ CreatedAt TEXT DEFAULT (datetime('now')),
+ ModifiedAt TEXT DEFAULT (datetime('now'))
+ )
+ ");
+ SafeCreateIndex("CREATE INDEX IF NOT EXISTS idx_alias_key ON AliasMap(NodeKey)", "AliasMap", "NodeKey");
+
// 设置数据库版本
- ExecuteNonQuery("PRAGMA user_version = 10000");
+ ExecuteNonQuery("PRAGMA user_version = 10010");
}
///