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:
parent
378bb6264c
commit
3f985aa235
@ -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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
353
UnitTests/Core/AliasTreeTests.cs
Normal file
353
UnitTests/Core/AliasTreeTests.cs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
660
doc/design/2026/别名树实施方案.md
Normal file
660
doc/design/2026/别名树实施方案.md
Normal 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×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
|
||||
<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 中的别名覆盖现有同名 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`,不持有自己的连接
|
||||
724
doc/design/2026/节点重命名参考方案.md
Normal file
724
doc/design/2026/节点重命名参考方案.md
Normal 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 个文件。核心思路:
|
||||
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<<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.cs(Pane 外壳)
|
||||
|
||||
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 即可恢复所有别名。
|
||||
260
src/Core/AliasTree/AliasDataStore.cs
Normal file
260
src/Core/AliasTree/AliasDataStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/Core/AliasTree/AliasNodeIdentity.cs
Normal file
190
src/Core/AliasTree/AliasNodeIdentity.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user