docs: 更新别名树实施方案 — 新增显示原则 + 修正标识/定位/联动描述

- 新增第7节「别名树显示原则」:二条件OR逻辑、深度限制、别名特殊规则、按需加载生命周期
- 第4节:标识改为 CreatePathId格式,定位改为 Models.ResolvePathId
- 第10节:JSON导出格式改为 key 字段
- 第11节:双向联动改为 ModelItem.Parent链 + ExpandAndSelect
- 清理 CleanupOnDemandNodes 逻辑文档化
This commit is contained in:
tian 2026-06-01 09:10:47 +08:00
parent 6260c175d4
commit fddefde1ed
2 changed files with 140 additions and 93 deletions

View File

@ -60,63 +60,57 @@
Revit 重新导出 NWC 再 append 进 NWD 后,`InstanceGuid` 可能整体变化。别名必须跨文件更新存活。
### 4.2 复合标识方案
### 4.2 复合标识方案(双字段)
```csharp
public struct AliasNodeIdentity
public readonly struct AliasNodeIdentity
{
/// <summary>沿 Parent 链拼接的路径字符串</summary>
public string HierarchicalPath { get; }
/// <summary>Navisworks 官方 PathIdCreatePathId 格式: "modelIndex:0/1/2"</summary>
public string IndexPath { get; }
/// <summary>同级同名节点的去重索引0-based</summary>
public int SiblingIndex { get; }
/// <summary>父链 DisplayName 拼接路径(可读、调试用</summary>
public string DisplayPath { get; }
/// <summary>序列化为 DB 主键</summary>
public string ToKey() => $"{HierarchicalPath}#{SiblingIndex}";
/// <summary>序列化为 DB 主键: "IndexPath||DisplayPath"</summary>
public string ToKey() => $"{IndexPath}||{DisplayPath}";
public static AliasNodeIdentity FromModelItem(ModelItem item)
{
// IndexPath: 使用 Navisworks 官方 API CreatePathId
var pid = Application.ActiveDocument.Models.CreatePathId(item);
string indexPath = $"{pid.ModelIndex}:{pid.PathId}";
// DisplayPath: 父链 DisplayName 拼接(如 "模型/楼层/墙#0"
var parts = new List<string>();
var current = item;
while (current != null)
{
parts.Insert(0, current.DisplayName ?? "?");
current = current.Parent;
}
while (current != null) { parts.Insert(0, current.DisplayName ?? "?"); current = current.Parent; }
string displayPath = string.Join("/", parts);
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;
// 同级同名去重
int siblingIndex = ComputeSiblingIndex(item);
if (siblingIndex > 0) displayPath += $"#{siblingIndex}";
return new AliasNodeIdentity(path, index);
return new AliasNodeIdentity(indexPath, displayPath);
}
}
```
### 4.3 回溯定位
使用 Navisworks 官方 API `Models.ResolvePathId()`,与项目路径功能(`BatchQueueManager`、`PathAnimationManager`)完全一致:
```csharp
// DB 中只有 HierarchicalPath + SiblingIndex。
// 在树中定位时:沿 path 逐层匹配 DisplayName
// 到叶子层级时用 SiblingIndex 在同名子节点中定位。
// IndexPath 格式: "modelIndex:PathId"(如 "0:0/0/35"
int colonIdx = indexPath.IndexOf(':');
int modelIndex = int.Parse(indexPath.Substring(0, colonIdx));
string pathId = indexPath.Substring(colonIdx + 1);
var pid = new ModelItemPathId { ModelIndex = modelIndex, PathId = pathId };
var item = doc.Models.ResolvePathId(pid);
```
不做名前缀匹配、不做手动树遍历。
### 4.4 容错
如果路径匹配不到目标节点(模型结构变化),标记为「孤儿别名」:
@ -327,13 +321,51 @@ XAML 中的使用方式(参考 `MediaControlIcons.xaml` 模式):
---
## 7. 批量编辑子节点(新增功能
## 7. 别名树显示原则(核心规则
### 7.1 场景
别名树不是 Selection Tree 的完整镜像。显示内容按以下规则决定:
### 7.1 二条件 OR 逻辑
| 条件 | 说明 | 示例 |
|------|------|------|
| **A: 层级限制内** | 深度 ≤ `MaxFullDisplayDepth`(默认 2的节点 | 根 → 第一层子节点 → 第二层子节点 |
| **B: 用户选中** | 在 Selection Tree 中选中的节点,无论多深 | 选中第 5 层节点 → 其完整路径显示在别名树中 |
满足任意一个条件即显示。两个条件都不满足的节点不显示。
### 7.2 深度限制
- `MaxFullDisplayDepth = 2`:初始构建只到第 2 层(根为第 0 层)
- 超限节点不添加展开箭头(无占位节点),用户无法手动展开
- 展开路径中的节点懒加载:有占位符 → `LazyLoadChildren`(全部子节点);无占位符 → `BuildNode`(只创建路径上的单个节点)
### 7.3 别名节点特殊规则
- **有别名的节点**:无论深度多少,启动时自动加载其完整路径并展开。由 `EnsureAliasedPathsVisible()``RebuildTree` / `SetDataStore` 后执行
- 超限别名节点的祖先会被展开,但祖先无别名则不保留占位符子节点
### 7.4 按需加载节点的生命周期
- 用户在 Selection Tree 中选择超限无别名节点 → 路径加载到别名树
- **下次选择另一个节点时**,上一次按需加载的无别名超限节点自动清理(`CleanupOnDemandNodes`
- 有别名的节点不受清理影响,始终保留
- `_onDemandNodeKeys` HashSet 追踪每次 `ExpandAndSelect` 中通过 `BuildNode` 创建的深层无别名节点
### 7.5 例外
- 开启别名面板后,树中已有节点被展开过的,其子节点保留(属用户主动操作)
- 通过内联编辑设置别名后,该节点自动变为「条件 A」的别名节点退出按需追踪
---
## 8. 批量编辑子节点(新增功能)
### 8.1 场景
Navisworks 中常见:一个父节点下 20 个子节点全叫「楼层」或「管道」,需要统一加前缀。
### 7.2 批量编辑对话框
### 8.2 批量编辑对话框
```
┌──────────────────────────────────┐
@ -356,7 +388,7 @@ Navisworks 中常见:一个父节点下 20 个子节点全叫「楼层」或
└──────────────────────────────────┘
```
### 7.3 四种编辑模式
### 8.3 四种编辑模式
| 模式 | 输入 | 结果 |
|------|------|------|
@ -365,27 +397,27 @@ Navisworks 中常见:一个父节点下 20 个子节点全叫「楼层」或
| 替换原名 | "自定义名称" | 所有子节点用同一名称 + 去重后缀 `_001` |
| 序号模板 | "管道_{0:D2}" | `管道_01`, `管道_02`... |
### 7.4 去重保证
### 8.4 去重保证
`MakeUnique()` 逻辑与 `AliasGenerator` 复用。预览中提前展示最终名称(含去重后缀)。
---
## 8. 别名节点 3D 高亮
## 9. 别名节点 3D 高亮
### 8.1 触发时机
### 9.1 触发时机
- 给节点设置别名时,自动在该节点上加永久颜色覆盖
- 清除别名时,同时清除颜色覆盖
### 8.2 颜色方案
### 9.2 颜色方案
```csharp
// 别名高亮色:半透明青色
static readonly Color AliasHighlightColor = Color.FromArgb(80, 0, 150, 200);
```
### 8.3 实现
### 9.3 实现
```csharp
public void ApplyAliasHighlight(ModelItem item)
@ -403,13 +435,13 @@ public void RemoveAliasHighlight(ModelItem item)
}
```
### 8.4 清理
### 9.4 清理
- 文档关闭时自然清理(颜色覆盖不持久化到 NWD
- 清除别名时同时清除颜色
- 全部别名清除提供「清除所有别名高亮」操作
### 8.5 与其他高亮的兼容
### 9.5 与其他高亮的兼容
项目已有 `ModelHighlightHelper`,使用 category 机制管理高亮。别名高亮用专用 category `"AliasHighlight"`
@ -421,9 +453,9 @@ ModelHighlightHelper.HighlightItems("AliasHighlight", itemCollection);
---
## 9. JSON 导入/导出
## 10. JSON 导入/导出
### 9.1 格式
### 10.1 格式
```json
{
@ -432,24 +464,18 @@ ModelHighlightHelper.HighlightItems("AliasHighlight", itemCollection);
"documentName": "Project.nwd",
"aliases": [
{
"path": "模型名/一楼/楼层",
"siblingIndex": 0,
"alias": "1F-办公层"
},
{
"path": "模型名/一楼/楼层",
"siblingIndex": 1,
"alias": "1F-大堂"
"key": "0:0/0/0||Floor2_mobile_yup.nwd/Architecture/2ND FLOOR/3' - 3\" x 4' - 3\"",
"alias": "第一个节点"
}
]
}
```
### 9.2 导入策略
### 10.2 导入策略
- **合并模式**JSON 中的别名覆盖现有同名 keyJSON 中没有的不动。
### 9.3 字段说明
### 10.3 字段说明
| 字段 | 用途 |
|------|------|
@ -459,57 +485,51 @@ ModelHighlightHelper.HighlightItems("AliasHighlight", itemCollection);
---
## 10. 双向联动机制
## 11. 双向联动机制
### 10.1 点击别名树 → 选中模型
### 11.1 点击别名树 → 选中模型
```csharp
private void OnAliasTreeSelected(AliasNodeViewModel node)
private void OnTreeViewSelected(AliasNodeViewModel node)
{
if (node.ModelItem == null) return;
_isInternalSelection = true; // 防止循环触发
_isInternalSelection = true;
var coll = new ModelItemCollection { node.ModelItem };
Application.ActiveDocument.CurrentSelection.SelectedItems = coll;
doc.CurrentSelection.Clear();
doc.CurrentSelection.CopyFrom(coll);
_isInternalSelection = false;
}
```
### 10.2 Navisworks 选中变化 → 别名树高亮
### 11.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);
}
});
if (_isInternalSelection) return;
var selected = doc.CurrentSelection.SelectedItems;
if (selected.Count == 1)
ExpandAndSelect(selected.First());
}
```
### 10.3 展开路径
### 11.3 展开路径(沿 ModelItem.Parent 链)
```csharp
private void ExpandAndHighlight(ModelItem item)
private void ExpandAndSelect(ModelItem item)
{
var identity = AliasNodeIdentity.FromModelItem(item);
// 1. 逐层沿 path 找 TreeViewItem展开 + 延时加载
// 2. 到目标层用 SiblingIndex 定位
// 3. IsSelected = true + BringIntoView()
// 1. 沿 Parent 链构建祖先列表(根 → 目标)
// 2. 在 rootNodes 中按 ModelItem.Equals 匹配根节点
// 3. 逐层向下:有占位 → LazyLoadChildren / 无占位 → BuildNode 单点创建
// 4. SelectTreeNode 选中最终节点
}
```
不使用 IndexPath 做运行时导航。IndexPath 仅在保存/加载别名时用于持久化(`CreatePathId` / `ResolvePathId`)。
---
## 11. 线程安全
## 12. 线程安全
**所有 Navisworks API 调用必须在主 STA 线程**(项目第 8 节硬约束)。
@ -530,9 +550,9 @@ private void ExpandAndHighlight(ModelItem item)
---
## 12. 文件组织与集成
## 13. 文件组织与集成
### 12.1 目录结构
### 13.1 目录结构
```
src/
@ -552,13 +572,13 @@ src/
│ └── AliasNodeViewModel.cs # 单节点 VM
```
### 12.2 命名空间
### 13.2 命名空间
- `NavisworksTransport.Core.AliasTree` — 插件入口、数据层
- `NavisworksTransport.UI.WPF.Views` — XAML 视图
- `NavisworksTransport.UI.WPF.ViewModels` — ViewModel
### 12.3 插件注册
### 13.3 插件注册
```csharp
// src/Core/AliasTree/AliasTreePlugin.cs
@ -585,7 +605,7 @@ public class AliasTreePlugin : DockPanePlugin
}
```
### 12.4 用户操作步骤
### 13.4 用户操作步骤
1. 启动 Navisworks → 打开模型
2. **View → Docking Windows → 别名导航树** 打开面板
@ -595,7 +615,7 @@ public class AliasTreePlugin : DockPanePlugin
---
## 13. 实施步骤(按优先级)
## 14. 实施步骤(按优先级)
### 阶段 1数据层2 天)
@ -640,7 +660,7 @@ public class AliasTreePlugin : DockPanePlugin
---
## 14. 风险与已知限制
## 15. 风险与已知限制
| 风险 | 缓解 |
|------|------|
@ -652,7 +672,7 @@ public class AliasTreePlugin : DockPanePlugin
---
## 15. 与现有功能的隔离
## 16. 与现有功能的隔离
- 不修改任何现有 `PathPlanningManager` / `PathDatabase` 核心逻辑(只在 DB 中加表)
- 不侵入 `LogisticsControlPanel`(独立 `DockPanePlugin`

View File

@ -30,6 +30,8 @@ namespace NavisworksTransport.UI.WPF.Views
/// <summary>保存别名前触发(确保数据库已创建)</summary>
internal event Action BeforeSaveAlias;
private Dictionary<string, AliasNodeViewModel> _nodeMap = new Dictionary<string, AliasNodeViewModel>();
/// <summary>记录 ExpandAndSelect 按需加载但无别名的深层节点,下次选择时清理</summary>
private HashSet<string> _onDemandNodeKeys = new HashSet<string>();
private bool _isInternalSelection = false;
private Autodesk.Navisworks.Api.ModelItem _currentSelectionItem;
private bool _built = false;
@ -448,6 +450,9 @@ namespace NavisworksTransport.UI.WPF.Views
LogManager.Info($"[别名树] ExpandAndSelect: target={item.DisplayName}");
// 0. 清理上次按需加载的无别名深层节点
CleanupOnDemandNodes();
// 1. 沿 Parent 链构建祖先列表(根 → 目标)
var chain = new System.Collections.Generic.List<Autodesk.Navisworks.Api.ModelItem>();
var current = item;
@ -518,6 +523,9 @@ namespace NavisworksTransport.UI.WPF.Views
{
child.Parent = treeNode;
treeNode.Children.Add(child);
// 超限且无别名 → 下次选择时清理
if (child.Depth >= MaxFullDisplayDepth && !child.HasAlias)
_onDemandNodeKeys.Add(child.NodeKey);
}
}
@ -590,6 +598,25 @@ namespace NavisworksTransport.UI.WPF.Views
}
}
/// <summary>
/// 清理上次按需加载的、无别名的深层节点。
/// ExpandAndSelect 每次调用前执行,确保前一次超限无别名节点被移除。
/// </summary>
private void CleanupOnDemandNodes()
{
if (_onDemandNodeKeys.Count == 0) return;
foreach (var key in _onDemandNodeKeys)
{
if (_nodeMap.TryGetValue(key, out var node))
{
node.Parent?.Children.Remove(node);
_nodeMap.Remove(key);
}
}
_onDemandNodeKeys.Clear();
}
/// <summary>
/// 确保所有已存别名的节点路径在树中可见。
/// 在 RebuildTree 后调用,对深层别名节点逐级加载其祖先。