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
This commit is contained in:
tian 2026-05-31 12:46:33 +08:00
parent 378bb6264c
commit 3f985aa235
8 changed files with 2207 additions and 1 deletions

View File

@ -52,10 +52,15 @@
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Data.SQLite">
<HintPath>..\TransportPlugin\packages\Stub.System.Data.SQLite.Core.NetFramework.1.0.118.0\lib\net46\System.Data.SQLite.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="UnitTests\Core\AliasTreeTests.cs" />
<Compile Include="UnitTests\Core\PathHelperTests.cs" />
<Compile Include="UnitTests\Core\SelectionClipBoxLockStateTests.cs" />
<Compile Include="UnitTests\Core\PathPlanningManagerHoistingCompletionTests.cs" />

View File

@ -146,6 +146,8 @@
<Compile Include="src\Core\Config\ConfigManager.cs" />
<Compile Include="src\Core\Config\CustomCategoryConfig.cs" />
<Compile Include="src\Core\Database\BackupManager.cs" />
<Compile Include="src\Core\AliasTree\AliasNodeIdentity.cs" />
<Compile Include="src\Core\AliasTree\AliasDataStore.cs" />
<Compile Include="src\Core\Services\TimeTagCalculator.cs" />
<Compile Include="src\Core\Services\TimeTagService.cs" />
<Compile Include="src\Core\Services\TimeTagExporter.cs" />

View File

@ -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<string, string>
{
{ 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<string, string>());
Assert.AreEqual(1, store.Count);
}
// ── 清除全部 ──
[TestMethod]
public void ClearAll_ShouldRemoveEverything()
{
var store = CreateStore();
store.SetAlias(Id("a", 0), "别名1");
store.SetAlias(Id("b", 1), "别名2");
store.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"]);
}
}
}

View File

@ -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
{
/// <summary>沿 Parent 链拼接的路径字符串</summary>
public string HierarchicalPath { get; }
/// <summary>同级同名节点的去重索引0-based</summary>
public int SiblingIndex { get; }
/// <summary>序列化为 DB 主键</summary>
public string ToKey() => $"{HierarchicalPath}#{SiblingIndex}";
public static AliasNodeIdentity FromModelItem(ModelItem item)
{
var parts = new List<string>();
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<string, string> LoadAll(); // 全量加载到内存缓存
int Count { get; } // 统计
// 孤儿别名
List<string> FindOrphans(HashSet<string> validKeys); // 检测
void PurgeOrphans(HashSet<string> validKeys); // 清理
}
```
### 5.3 与 PathDatabase 的关系
`AliasDataStore` 不持有独立 SQLiteConnection。通过 `PathDatabase.Connection` 操作,使别名表与路径/碰撞数据共享同一事务生命周期和备份机制。
---
## 6. 面板 UI 设计
### 6.1 布局
```
┌──────────────────────────────────────────┐
│ 顶部图标工具栏24px 高) │
│ [当前: 未命名节点标题] [💾] [⏬] [⏫] [⚙] [🗑] │
│ 保存 导出 导入 自动 清除 │
│ │
│ [别名输入框 ] [✓] │
├──────────────────────────────────────────┤
│ TreeView别名导航树
│ ├─ 模型名(灰色斜体=无别名) │
│ │ ├─ 一楼 → "1F-大厅" ◆蓝色粗体 │
│ │ ├─ 一楼 → 灰色斜体 │
│ │ ├─ 一楼_001 → 灰色斜体 │
│ │ └─ ... │
│ └─ 另一个模型 │
│ └─ ... │
└──────────────────────────────────────────┘
```
视觉规范:
- **有别名**:蓝色 (#1565C0) 粗体,加 ◆ 前缀图标
- **无别名**:灰色 (#888) 斜体,显示原 DisplayName
- **编辑中**`TextBox` 覆盖 TextBlock背景浅黄
- **孤儿别名**:灰色删除线
- **鼠标悬停行**:背景浅蓝 + 显示铅笔按钮 ✎
- **工具栏**:图标按钮 24×24Navisworks 蓝色系,鼠标悬停高亮
### 6.2 顶部图标工具栏
按钮统一使用 WPF `Path` 几何图形(与项目 `MediaControlIcons.xaml` 同模式),不依赖外部图片文件。
工具栏风格参考 Visual Studio / Navisworks 内置面板:紧凑横向排列,仅图标,悬停显示 ToolTip。
| 图标 | GeometryPath 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
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 保存图标(磁盘) -->
<Geometry x:Key="SaveIconGeometry">
M15,9H5V5H15M15,19H5V15H15M17,3H3V21H17V16L21,12L17,8V3Z
</Geometry>
<!-- 导出图标(向上箭头出盒子) -->
<Geometry x:Key="ExportIconGeometry">
M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z
</Geometry>
<!-- 导入图标(向下箭头入盒子) -->
<Geometry x:Key="ImportIconGeometry">
M5,10H19V12H5M12,5L5,12H9V21H15V12H19L12,5Z
</Geometry>
<!-- 自动生成图标(齿轮) -->
<Geometry x:Key="AutoGenIconGeometry">
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 ...
</Geometry>
<!-- 清除图标(垃圾桶) -->
<Geometry x:Key="ClearIconGeometry">
M6,19V7H18V19H6M9,4H15L14.5,3H9.5L9,4M19,5V7H5V5H8L8.5,4H15.5L16,5H19Z
</Geometry>
<!-- 别名工具栏图标按钮样式紧凑24×24 -->
<Style x:Key="AliasToolbarButtonStyle" TargetType="Button">
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Margin" Value="1,0"/>
<Setter Property="Padding" Value="2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource NavisworksTextBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="ToolTipService.ShowOnDisabled" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderThickness="0"
CornerRadius="2">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background"
Value="{StaticResource NavisworksLightBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background"
Value="{StaticResource NavisworksButtonBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="#FFBBBBBB"/>
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
```
XAML 中的使用方式(参考 `MediaControlIcons.xaml` 模式):
```xml
<Button Style="{StaticResource AliasToolbarButtonStyle}"
ToolTip="保存别名" Click="OnSaveClick">
<Path Data="{StaticResource SaveIconGeometry}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
```
合并到控件 XAML 的 `ResourceDictionary.MergedDictionaries`
```xml
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="../Resources/NavisworksStyles.xaml"/>
<ResourceDictionary Source="../Resources/AliasTreeIcons.xaml"/>
</ResourceDictionary.MergedDictionaries>
```
### 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 中的别名覆盖现有同名 keyJSON 中没有的不动。
### 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` | 主线程(必须) |
| 读/写 SQLiteAliasDataStore | 可通过 `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 生成、去重、回溯定位
### 阶段 2VM + 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`,不持有自己的连接

View File

@ -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 个文件。核心思路:
AliasTreePaneNavisworks DockPane 外壳。
AliasTreeControlWPF 别名树面板支持悬停铅笔编辑、顶部快捷栏、右键菜单、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<<AliasNode> Children { get; set; } = new List<<AliasNode>();
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.csPane 外壳)
using Autodesk.Navisworks.Api.Plugins;
namespace AliasTree
{
public class AliasTreePane : Pane
{
public AliasTreePane()
{
this.Content = new AliasTreeControl();
}
}
}
4. AliasTreeControl.xaml界面布局
<UserControl x:Class="AliasTree.AliasTreeControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:AliasTree">
<DockPanel>
<!-- 顶部快捷栏 -->
<ToolBar DockPanel.Dock="Top" Height="32">
<TextBlock Text="当前选中:" VerticalAlignment="Center" Margin="4,0"/>
<TextBlock x:Name="TbCurrentName" Text="无" VerticalAlignment="Center"
FontWeight="Bold" Margin="4,0" MaxWidth="150" TextTrimming="CharacterEllipsis"/>
<Separator/>
<TextBox x:Name="TxtQuickAlias" Width="120" ToolTip="输入别名后回车"
KeyDown="TxtQuickAlias_KeyDown"/>
<Button Content="✓ 保存" Click="BtnQuickSave_Click" ToolTip="为当前选中节点保存别名"/>
<Separator/>
<Button Content="导出CSV" Click="BtnExport_Click"/>
<Button Content="导入CSV" Click="BtnImport_Click"/>
<Separator/>
<Button Content="自动生成" Click="BtnAutoGen_Click" ToolTip="按父节点路径自动生成别名"/>
</ToolBar>
<!-- 别名树 -->
<TreeView x:Name="AliasTreeView"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:AliasNode}" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1">
<!-- 显示模式:无别名时灰色斜体显示原名 -->
<TextBlock Text="{Binding DisplayText}" VerticalAlignment="Center" MinWidth="60">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Black"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Alias}" Value="">
<Setter Property="Foreground" Value="Gray"/>
<Setter Property="FontStyle" Value="Italic"/>
</DataTrigger>
<DataTrigger Binding="{Binding Alias}" Value="{x:Null}">
<Setter Property="Foreground" Value="Gray"/>
<Setter Property="FontStyle" Value="Italic"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- 编辑模式默认隐藏IsEditing=True 时显示 -->
<TextBox Text="{Binding Alias, UpdateSourceTrigger=PropertyChanged}"
Width="120" Visibility="Collapsed" VerticalAlignment="Center"
KeyDown="AliasTextBox_KeyDown" LostFocus="AliasTextBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<!-- 铅笔图标:鼠标悬停该行时显示 -->
<Button Content="✎" Margin="4,0,0,0" Padding="2,0"
Click="BtnEdit_Click" Tag="{Binding}" VerticalAlignment="Center">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=TreeViewItem}, Path=IsMouseOver}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="False"/>
<EventSetter Event="Expanded" Handler="TreeViewItem_Expanded"/>
<EventSetter Event="Selected" Handler="TreeViewItem_Selected"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="编辑别名" Click="CtxEdit_Click"/>
<MenuItem Header="清除别名" Click="CtxClear_Click"/>
<Separator/>
<MenuItem Header="在视图中聚焦" Click="CtxFocus_Click"/>
</ContextMenu>
</Setter.Value>
</Setter>
</Style>
</TreeView.ItemContainerStyle>
</TreeView>
</DockPanel>
</UserControl>
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<<Guid, string> _aliasMap = new Dictionary<<Guid, string>();
private List<<AliasNode> _rootNodes = new List<<AliasNode>();
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<<AliasNode>();
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<<AliasNode> nodes, Guid guid, List<<AliasNode> 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<TextBox>(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<string> { "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<<AliasNode> 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<<AliasNode> nodes)
{
foreach (var n in nodes)
{
n.IsEditing = false;
ClearAllEditing(n.Children);
}
}
private static T FindVisualChild<T>(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<T>(child);
if (result != null) return result;
}
return null;
}
private static string[] ParseCsvLine(string line)
{
var result = new List<string>();
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<<Guid, string> _result = new Dictionary<<Guid, string>();
private HashSet<string> _usedNames = new HashSet<string>();
public Dictionary<<Guid, string> 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`<br>`Autodesk.Navisworks.Automation.dll`<br>位置通常在:`C:\Program Files\Autodesk\Navisworks Manage 2024\` |
| **输出路径** | 编译后,将生成的 DLL 与 XAML/BAML 一起放入:<br>`%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 即可恢复所有别名。

View File

@ -0,0 +1,260 @@
using NavisworksTransport.Utils;
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.Linq;
namespace NavisworksTransport.Core.AliasTree
{
/// <summary>
/// 别名数据存储 — CRUD 操作 + 与 PathDatabase 对接。
///
/// 不持有独立 SQLiteConnection通过 PathDatabase.Connection 操作,
/// 使别名表与路径/碰撞数据共享同一事务生命周期和备份机制。
///
/// 线程安全:
/// - 构造/打开/关闭在主线程PathDatabase 生命周期同步)
/// - 后台线程读操作通过 Connection 上的锁保护
/// </summary>
public class AliasDataStore
{
private readonly SQLiteConnection _connection;
private readonly object _lock = new object();
/// <summary>
/// 构造 AliasDataStore使用 PathDatabase 的已有连接
/// </summary>
public AliasDataStore(SQLiteConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
/// <summary>
/// 确保 AliasMap 表存在(在 PathDatabase.CreateTables 中已自动创建,
/// 这是提供给独立初始化场景的保障)
/// </summary>
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();
}
}
}
// ── 单条操作 ──
/// <summary>
/// 读取别名。返回 null 表示无别名。
/// </summary>
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;
}
}
}
/// <summary>
/// 写入别名Upsert 语义 — 存在则更新,不存在则插入)
/// </summary>
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();
}
}
}
}
}
/// <summary>
/// 删除别名
/// </summary>
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();
}
}
}
// ── 批量操作 ──
/// <summary>
/// 删除指定路径前缀下的所有别名(用于"清除某分支下全部别名"
/// </summary>
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();
}
}
}
/// <summary>
/// 全量加载所有别名到内存字典。key=NodeKey, value=AliasText
/// </summary>
public Dictionary<string, string> LoadAll()
{
var result = new Dictionary<string, string>();
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;
}
/// <summary>
/// 当前别名总数
/// </summary>
public int Count
{
get
{
lock (_lock)
{
using (var cmd = new SQLiteCommand(
"SELECT COUNT(*) FROM AliasMap", _connection))
{
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
}
}
/// <summary>
/// 批量导入(合并模式:覆盖已有 key新增不存在的 key
/// </summary>
public void ImportBatch(Dictionary<string, string> 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;
}
}
}
}
/// <summary>
/// 清除所有别名(危险操作)
/// </summary>
public void ClearAll()
{
lock (_lock)
{
using (var cmd = new SQLiteCommand("DELETE FROM AliasMap", _connection))
{
cmd.ExecuteNonQuery();
}
}
}
}
}

View File

@ -0,0 +1,190 @@
using Autodesk.Navisworks.Api;
using System;
using System.Collections.Generic;
using System.Text;
namespace NavisworksTransport.Core.AliasTree
{
/// <summary>
/// 稳定的复合节点标识 — 替代 InstanceGuid 用于持久化。
///
/// 基于父链 DisplayName 拼接 + 同级同名节点去重索引 构成唯一 key。
/// 即使 Revit 重新导出 NWC/append 进 NWD只要 DisplayName 层级结构不变,
/// 别名就能正确关联。
///
/// 设计原则:
/// - 序列化格式: "path/to/node#index",便于 SQLite 存储和 JSON 导出
/// - 不可变结构体(值语义),可用作 Dictionary key
/// - 从 ModelItem 构造时在主线程调用Navisworks API
/// - 反序列化/比较时纯字符串操作(可在后台线程安全使用)
/// </summary>
public readonly struct AliasNodeIdentity : IEquatable<AliasNodeIdentity>
{
/// <summary>父链 DisplayName 拼接路径</summary>
public string HierarchicalPath { get; }
/// <summary>同级同名节点中的去重索引0-based</summary>
public int SiblingIndex { get; }
/// <summary>
/// 构造复合标识
/// </summary>
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;
}
/// <summary>
/// 从 ModelItem 构造复合标识。
/// 注意:必须在主线程中调用,因为涉及 Navisworks API 访问。
/// </summary>
public static AliasNodeIdentity FromModelItem(ModelItem item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
// 1. 沿 Parent 链收集 DisplayName
var parts = new List<string>();
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);
}
/// <summary>
/// 序列化为数据库主键字符串
/// </summary>
public string ToKey()
{
return $"{HierarchicalPath}#{SiblingIndex}";
}
/// <summary>
/// 从 key 字符串反序列化
/// </summary>
public static AliasNodeIdentity FromKey(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentException("key 不能为空", nameof(key));
int separatorIndex = key.LastIndexOf('#');
if (separatorIndex < 0 || separatorIndex >= key.Length - 1)
throw new FormatException($"无法从 key 解析 AliasNodeIdentity: {key}");
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);
}
/// <summary>
/// 提前验证 key 字符串格式是否合法(不抛出异常)
/// </summary>
public static bool TryParseKey(string key, out AliasNodeIdentity identity)
{
identity = default;
if (string.IsNullOrEmpty(key)) return false;
int separatorIndex = key.LastIndexOf('#');
if (separatorIndex < 0 || separatorIndex >= key.Length - 1) return false;
string path = key.Substring(0, separatorIndex);
string indexStr = key.Substring(separatorIndex + 1);
if (!int.TryParse(indexStr, out int siblingIndex)) return false;
if (string.IsNullOrEmpty(path)) return false;
if (siblingIndex < 0) return false;
identity = new AliasNodeIdentity(path, siblingIndex);
return true;
}
/// <summary>
/// 仅检查 HierarchicalPath 前缀匹配 — 用于"批量删除该分支下所有别名"
/// </summary>
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();
}
}
}

View File

@ -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");
}
/// <summary>