refactor: ModelItemTreeWalker 通用遍历工具(统一 IsHidden 剪枝 + 聚合盒剪枝 + 后序传播)

新增 src/Utils/ModelItemTreeWalker.cs:
- WalkVisible/WalkModels:深度遍历,IsHidden 剪枝内置(父隐藏 → 整棵子树跳过)
- 回调返回 ModelItemWalkAction(Prune/Continue/Hit)——剪枝条件与深入策略由调用方决定
- Hit 触发父级后序传播(postHitVisit)——共享祖先只上传一次(消除 K×D 枚举)
- onHidden 回调(隐藏节点统计)

迁移:
- SectionBoxExporter.TraverseAndCollect → walker(聚合盒剪枝 + 后序传播语义不变)
- ModelItemAnalysisHelper.GetAllVisibleGeometryItems → walker(栈式循环替换,统计保留)

修复:
- run-unit-tests.bat 过滤器排除 NavisworksIntegrationF1(F1 测试此前会被单元测试集选中,
  无主 NW 环境时失败)

验证:单元 244/244、集成 25/25(SectionBox 61ms)、Architecture 111ms(无回退)、对象 228 一致
This commit is contained in:
tian 2026-08-06 10:53:18 +08:00
parent da81672782
commit 818a5e0ad7
5 changed files with 197 additions and 112 deletions

View File

@ -339,6 +339,7 @@
<Compile Include="src\Utils\CoordinateConverter.cs" /> <Compile Include="src\Utils\CoordinateConverter.cs" />
<Compile Include="src\Utils\FloorDetector.cs" /> <Compile Include="src\Utils\FloorDetector.cs" />
<Compile Include="src\Utils\ModelItemAnalysisHelper.cs" /> <Compile Include="src\Utils\ModelItemAnalysisHelper.cs" />
<Compile Include="src\Utils\ModelItemTreeWalker.cs" />
<Compile Include="src\Utils\GeometryHelper.cs" /> <Compile Include="src\Utils\GeometryHelper.cs" />
<Compile Include="src\Utils\GeometryCacheManager.cs" /> <Compile Include="src\Utils\GeometryCacheManager.cs" />
<Compile Include="src\Utils\DialogHelper.cs" /> <Compile Include="src\Utils\DialogHelper.cs" />

View File

@ -48,7 +48,7 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
%VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH% /TestCaseFilter:"TestCategory!=NavisworksIntegration" %VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH% /TestCaseFilter:"TestCategory!=NavisworksIntegration&TestCategory!=NavisworksIntegrationF1"
if errorlevel 1 ( if errorlevel 1 (
echo Unit tests failed! echo Unit tests failed!
exit /b 1 exit /b 1

View File

@ -279,12 +279,24 @@ namespace NavisworksTransport.Core
var visibleSet = new HashSet<ModelItem>(); // 收集需要保留的节点(对象+祖先) var visibleSet = new HashSet<ModelItem>(); // 收集需要保留的节点(对象+祖先)
// 第一遍:遍历整个模型树,收集盒内对象和可见节点 // 第一遍:遍历整个模型树,收集盒内对象和可见节点
// (统一走 ModelItemTreeWalkerIsHidden 剪枝 + 组节点聚合盒剪枝 + 后序传播内置)
foreach (var model in document.Models) foreach (var model in document.Models)
{ {
var rootItem = model.RootItem; if (model.RootItem == null)
if (rootItem != null)
{ {
TraverseAndCollect(model.RootItem, sectionBox, result.ObjectsInBox, visibleSet, prune); continue;
}
bool rootHit = ModelItemTreeWalker.WalkVisible(
model.RootItem,
node => TraverseVisit(node, sectionBox, result.ObjectsInBox, visibleSet, prune),
postHitVisit: node => visibleSet.Add(node),
onHidden: null);
// 根节点本身也是命中对象的祖先(原 AncestorsAndSelf 含根)
if (rootHit)
{
visibleSet.Add(model.RootItem);
} }
} }
@ -305,73 +317,64 @@ namespace NavisworksTransport.Core
} }
/// <summary> /// <summary>
/// 递归遍历收集盒内对象和可见节点。 /// 模型树遍历访问回调ModelItemTreeWalker 调用):
/// 返回:本子树是否存在盒内对象(后序传播用——命中路径的祖先由父级逐层加入 visibleSet /// - 组节点聚合盒剪枝prune 时)→ Prune 或 Continue
/// - 几何节点:盒相交 + 有体积 → Hit加入盒内对象与可见集合否则 Prune几何节点不深入
/// </summary> /// </summary>
private bool TraverseAndCollect(ModelItem item, BoundingBox3D sectionBox, private static ModelItemWalkAction TraverseVisit(
List<ModelItem> objectsInBox, HashSet<ModelItem> visibleSet, bool prune) ModelItem item,
BoundingBox3D sectionBox,
List<ModelItem> objectsInBox,
HashSet<ModelItem> visibleSet,
bool prune)
{ {
if (item == null || item.IsHidden) return false;
bool hasGeometry = item.HasGeometry; bool hasGeometry = item.HasGeometry;
// 子树剪枝非几何节点组节点先查聚合包围盒NW 内部缓存O(1))。 // 子树剪枝非几何节点组节点先查聚合包围盒NW 内部缓存O(1))。
// 组节点盒与剖面盒不相交 → 其整棵子树内不可能存在盒内对象 → 跳过全部后代。 // 组节点盒与剖面盒不相交 → 其整棵子树内不可能存在盒内对象 → 跳过全部后代。
// 收益:大型模型下远处楼层/区域一次调用剪掉整棵子树,省下所有后代的 // 收益:大型模型下远处楼层/区域一次调用剪掉整棵子树,省下所有后代的
// Children/IsHidden/HasGeometry API 调用。 // Children/IsHidden/HasGeometry API 调用。
if (prune && !hasGeometry) if (!hasGeometry)
{ {
var groupBox = item.BoundingBox(); if (prune && !BoundingBoxesIntersect(item.BoundingBox(), sectionBox))
if (!BoundingBoxesIntersect(groupBox, sectionBox))
{ {
return false; return ModelItemWalkAction.Prune;
}
}
// 如果有几何体,检查是否在盒内
if (hasGeometry)
{
var bbox = item.BoundingBox();
// 检查是否有体积三个方向尺寸都大于0
double sizeX = bbox.Max.X - bbox.Min.X;
double sizeY = bbox.Max.Y - bbox.Min.Y;
double sizeZ = bbox.Max.Z - bbox.Min.Z;
bool hasVolume = sizeX > 0.0001 && sizeY > 0.0001 && sizeZ > 0.0001;
// 有体积且与剖面盒相交 → 是目标对象(只加入自己,祖先由父级后序上传)
if (hasVolume && BoundingBoxesIntersect(bbox, sectionBox))
{
objectsInBox.Add(item);
visibleSet.Add(item);
return true;
} }
// 找到几何体,停止向下遍历(原逻辑) return ModelItemWalkAction.Continue;
return false;
} }
// 没有几何体,继续遍历子节点;后序传播:任一子命中 → 自己是祖先,加入 visibleSet // 几何体节点:检查是否在盒内
bool anyChildHit = false; var bbox = item.BoundingBox();
foreach (var child in item.Children)
{
if (TraverseAndCollect(child, sectionBox, objectsInBox, visibleSet, prune))
{
anyChildHit = true;
}
}
if (anyChildHit) // 检查是否有体积三个方向尺寸都大于0
double sizeX = bbox.Max.X - bbox.Min.X;
double sizeY = bbox.Max.Y - bbox.Min.Y;
double sizeZ = bbox.Max.Z - bbox.Min.Z;
bool hasVolume = sizeX > 0.0001 && sizeY > 0.0001 && sizeZ > 0.0001;
// 有体积且与剖面盒相交 → 是目标对象(只加入自己,祖先由后序传播上传)
if (hasVolume && BoundingBoxesIntersect(bbox, sectionBox))
{ {
objectsInBox.Add(item);
visibleSet.Add(item); visibleSet.Add(item);
return ModelItemWalkAction.Hit;
} }
return anyChildHit; // 找到几何体但不在盒内,停止向下遍历(原逻辑)
return ModelItemWalkAction.Prune;
} }
/// <summary> /// <summary>
/// 【使用原来的兄弟节点算法】计算需要隐藏的节点 /// 获取剖面盒内的所有对象(公开方法供外部调用)
/// 【已优化】内部使用 GetObjectsAndHiddenItems 实现一次遍历
/// </summary> /// </summary>
public List<ModelItem> GetObjectsInSectionBox(Document document, BoundingBox3D sectionBox)
{
var result = GetObjectsAndHiddenItems(document, sectionBox);
return result.ObjectsInBox;
}
private void CalculateHiddenItems(HashSet<ModelItem> visibleSet, ModelItemCollection itemsToHide) private void CalculateHiddenItems(HashSet<ModelItem> visibleSet, ModelItemCollection itemsToHide)
{ {
// 1. 收集唯一的父节点 // 1. 收集唯一的父节点
@ -404,20 +407,7 @@ namespace NavisworksTransport.Core
} }
} }
/// <summary> private static bool BoundingBoxesIntersect(BoundingBox3D a, BoundingBox3D b)
/// 获取剖面盒内的所有对象(公开方法供外部调用)
/// 【已优化】内部使用 GetObjectsAndHiddenItems 实现一次遍历
/// </summary>
public List<ModelItem> GetObjectsInSectionBox(Document document, BoundingBox3D sectionBox)
{
var result = GetObjectsAndHiddenItems(document, sectionBox);
return result.ObjectsInBox;
}
/// <summary>
/// 检查两个包围盒是否相交
/// </summary>
private bool BoundingBoxesIntersect(BoundingBox3D a, BoundingBox3D b)
{ {
return (a.Min.X <= b.Max.X && a.Max.X >= b.Min.X) && return (a.Min.X <= b.Max.X && a.Max.X >= b.Min.X) &&
(a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y) && (a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y) &&

View File

@ -382,17 +382,8 @@ namespace NavisworksTransport.Utils
var totalModelItems = Application.ActiveDocument.Models.RootItemDescendantsAndSelf.Count(); var totalModelItems = Application.ActiveDocument.Models.RootItemDescendantsAndSelf.Count();
LogManager.Info($"[几何体获取] 模型总数统计: {totalModelItems} 个模型项"); LogManager.Info($"[几何体获取] 模型总数统计: {totalModelItems} 个模型项");
// 使用 Stack 模拟递归避免栈溢出
// Stack 初始容量设为 1000 以减少扩容
var stack = new Stack<ModelItem>(1000);
var visibleItems = new List<ModelItem>(); var visibleItems = new List<ModelItem>();
// 将所有模型的根节点压入栈
foreach (var model in Application.ActiveDocument.Models)
{
stack.Push(model.RootItem);
}
// 统计信息 // 统计信息
int hiddenSkipped = 0; // 跳过的隐藏节点(包括其子节点) int hiddenSkipped = 0; // 跳过的隐藏节点(包括其子节点)
int visibleCount = 0; // 可见节点总数 int visibleCount = 0; // 可见节点总数
@ -404,28 +395,24 @@ namespace NavisworksTransport.Utils
int mixedCount = 0; // 混合类型几何体 int mixedCount = 0; // 混合类型几何体
int noGeometryCount = 0; // 无几何体节点 int noGeometryCount = 0; // 无几何体节点
while (stack.Count > 0) // 统一走 ModelItemTreeWalkerIsHidden 剪枝(父隐藏 → 整棵子树跳过)内置
{ ModelItemTreeWalker.WalkModels(
var item = stack.Pop(); Application.ActiveDocument.Models,
item =>
// 关键剪枝:如果节点显式隐藏,则跳过其整个分支(它的所有子节点都不可见)
if (item.IsHidden)
{ {
hiddenSkipped++; visibleCount++;
continue;
}
visibleCount++; // 🔥 优化只提取复合对象IsComposite = true或独立实体节点HasGeometry
// 原因:复合对象(如窗户)包含多个实体节点(玻璃、框架、把手)
// 我们只需要检测复合对象,不需要检测其子节点
bool isComposite = item.IsComposite;
bool hasGeometry = item.HasGeometry;
// 🔥 优化只提取复合对象IsComposite = true或独立实体节点HasGeometry if (!isComposite && !hasGeometry)
// 原因:复合对象(如窗户)包含多个实体节点(玻璃、框架、把手) {
// 我们只需要检测复合对象,不需要检测其子节点 // 空节点:继续遍历子节点
bool isComposite = item.IsComposite; return ModelItemWalkAction.Continue;
bool hasGeometry = item.HasGeometry; }
if (isComposite || hasGeometry)
{
bool addedCurrentItem = false;
// 复合对象或独立实体节点:检查几何体类型 // 复合对象或独立实体节点:检查几何体类型
var geometry = item.Geometry; var geometry = item.Geometry;
@ -442,9 +429,11 @@ namespace NavisworksTransport.Utils
else else
mixedCount++; mixedCount++;
visibleItems.Add(item); // 只保留包含三角形的几何体 visibleItems.Add(item); // 只保留包含三角形的几何体
addedCurrentItem = true; // 已添加 → 截断子树(本节点直接作为几何候选)
return ModelItemWalkAction.Prune;
} }
else if (primitiveTypes == PrimitiveTypes.Lines)
if (primitiveTypes == PrimitiveTypes.Lines)
{ {
lineOnlyCount++; lineOnlyCount++;
// 不添加:纯线条几何体过滤掉 // 不添加:纯线条几何体过滤掉
@ -468,7 +457,7 @@ namespace NavisworksTransport.Utils
{ {
mixedCount++; mixedCount++;
visibleItems.Add(item); // 其他混合类型保留 visibleItems.Add(item); // 其他混合类型保留
addedCurrentItem = true; return ModelItemWalkAction.Prune;
} }
} }
else else
@ -477,25 +466,10 @@ namespace NavisworksTransport.Utils
// 不添加:无几何体数据 // 不添加:无几何体数据
} }
// 只有当前节点已经可直接作为几何候选时,才截断子树。 // 当前节点没有可用三角几何:继续向下遍历,避免把真实几何子节点整棵截断
// 如果当前节点本身没有可用三角几何,则继续向下遍历,避免把真实几何子节点整棵截断。 return ModelItemWalkAction.Continue;
if (!addedCurrentItem) },
{ onHidden: item => hiddenSkipped++);
foreach (var child in item.Children)
{
stack.Push(child);
}
}
}
else
{
// 空节点:继续遍历子节点
foreach (var child in item.Children)
{
stack.Push(child);
}
}
}
var elapsed = (DateTime.Now - startTime).TotalMilliseconds; var elapsed = (DateTime.Now - startTime).TotalMilliseconds;

View File

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using Autodesk.Navisworks.Api;
namespace NavisworksTransport.Utils
{
/// <summary>
/// 模型树遍历访问结果(回调决定本节点的深入/剪枝/命中行为)。
/// </summary>
public enum ModelItemWalkAction
{
/// <summary>剪掉整棵子树(不再访问任何后代)</summary>
Prune,
/// <summary>继续访问后代节点</summary>
Continue,
/// <summary>命中停止深入本节点子树并触发父级后序传播postHitVisit</summary>
Hit
}
/// <summary>
/// 模型树深度遍历通用工具。
///
/// 内置语义:
/// - IsHidden 剪枝:节点隐藏 → 整棵子树跳过Navisworks 层级可见性规则:
/// 父隐藏则所有子节点都不可见,无需深入)
/// - 回调返回 Hit 时触发父级后序传播postHitVisit——用于收集
/// "命中对象的所有祖先"(共享祖先只被上传一次,避免 K×D 次枚举)
///
/// 用途剖面盒导出SectionBoxExporter、可见几何体收集
/// ModelItemAnalysisHelper等模型树遍历统一复用。
/// </summary>
public static class ModelItemTreeWalker
{
/// <summary>
/// 深度遍历模型树。
/// </summary>
/// <param name="item">当前节点(通常从 model.RootItem 开始)</param>
/// <param name="visit">访问回调(决定 Prune/Continue/Hit</param>
/// <param name="postHitVisit">后序传播:子树有 Hit 时对祖先调用(用于收集祖先集合)</param>
/// <param name="onHidden">隐藏节点通知(仅统计用,隐藏节点不会触发 visit</param>
/// <returns>本子树是否存在 Hit</returns>
public static bool WalkVisible(
ModelItem item,
Func<ModelItem, ModelItemWalkAction> visit,
Action<ModelItem> postHitVisit = null,
Action<ModelItem> onHidden = null)
{
if (item == null)
{
return false;
}
// 关键剪枝:节点隐藏 → 整棵子树跳过(其所有子节点都不可见)
if (item.IsHidden)
{
onHidden?.Invoke(item);
return false;
}
ModelItemWalkAction action = visit(item);
if (action == ModelItemWalkAction.Prune)
{
return false;
}
if (action == ModelItemWalkAction.Hit)
{
return true;
}
// Continue深入子节点任一子节点命中 → 本节点触发后序传播
bool anyChildHit = false;
foreach (var child in item.Children)
{
if (WalkVisible(child, visit, postHitVisit, onHidden))
{
anyChildHit = true;
}
}
if (anyChildHit)
{
postHitVisit?.Invoke(item);
}
return anyChildHit;
}
/// <summary>
/// 遍历多个模型的根Application.ActiveDocument.Models 场景)。
/// 返回任意模型根命中时根节点本身也触发 postHitVisit根是命中对象祖先之一
/// </summary>
public static bool WalkModels(
IEnumerable<Model> models,
Func<ModelItem, ModelItemWalkAction> visit,
Action<ModelItem> postHitVisit = null,
Action<ModelItem> onHidden = null)
{
bool anyHit = false;
foreach (var model in models)
{
if (model?.RootItem == null)
{
continue;
}
bool rootHit = WalkVisible(model.RootItem, visit, postHitVisit, onHidden);
if (rootHit)
{
postHitVisit?.Invoke(model.RootItem);
anyHit = true;
}
}
return anyHit;
}
}
}