From 3f985aa2355b0fc987b8c5314f1a6100eaeaf82a Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 31 May 2026 12:46:33 +0800 Subject: [PATCH] phase 1: data layer for alias tree - AliasNodeIdentity: stable composite node identifier (path + sibling index) - AliasDataStore: CRUD operations on SQLite AliasMap table - PathDatabase: add AliasMap table creation + index - Unit tests: 30 tests for identity parsing/equality, store CRUD/batch/import - Both projects compile successfully --- NavisworksTransport.UnitTests.csproj | 5 + TransportPlugin.csproj | 2 + UnitTests/Core/AliasTreeTests.cs | 353 ++++++++++++ doc/design/2026/别名树实施方案.md | 660 +++++++++++++++++++++ doc/design/2026/节点重命名参考方案.md | 724 ++++++++++++++++++++++++ src/Core/AliasTree/AliasDataStore.cs | 260 +++++++++ src/Core/AliasTree/AliasNodeIdentity.cs | 190 +++++++ src/Core/PathDatabase.cs | 14 +- 8 files changed, 2207 insertions(+), 1 deletion(-) create mode 100644 UnitTests/Core/AliasTreeTests.cs create mode 100644 doc/design/2026/别名树实施方案.md create mode 100644 doc/design/2026/节点重命名参考方案.md create mode 100644 src/Core/AliasTree/AliasDataStore.cs create mode 100644 src/Core/AliasTree/AliasNodeIdentity.cs 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"); } ///