diff --git a/CHANGELOG.md b/CHANGELOG.md index fb75205..3563c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # NavisworksTransport 变更日志 +## [0.18.3] - 2026-08-06 + +### ⚡ 性能优化 + +- **剖面盒遍历组节点聚合盒剪枝(大型模型 ~1.8 倍提速)**: + - `TraverseAndCollect` 对非几何节点(组节点)先查聚合包围盒,与剖面盒不相交 → **整棵子树跳过**(不再深入任何后代) + - 前提验证:NW 的 `BoundingBox()` 为文档级缓存(O(1) 读取,Architecture.nwd 2 万节点 + 全厂设备模型 403MB 实测全 0ms)——真正成本是每节点 Children/IsHidden/HasGeometry API 调用,剪枝省掉的正是这些 + - 实测(全厂设备模型 403MB,100×100×40 测试盒,3521 命中对象):**129.9s → 72.9s**;细长路径剖面盒(相交子树少)收益比例更高 +- **AncestorsAndSelf 后序传播(消除 K×D 次枚举,再降 ~15%)**: + - 命中对象只加入自己,祖先由父级 post-order 逐层上传(`TraverseAndCollect` 返回子树命中标志)——共享祖先只被加入一次,消除 K 个命中对象 × 树深 D 的 NW API 枚举 + - 实测(同测试环境):**72.9s → 62.0s**;两项叠加 **129.9s → 62.0s(~2.1 倍)** +- 正确性验证:盒内对象/隐藏节点与优化前完全一致(3521/80043;Architecture.nwd 228/4928) + +### 🧪 测试基础设施 + +- `export-section-box` 测试端点增加 `prune` 参数 + `traversalMs` 计时(性能对比用) +- `bbox-profiling` 端点增加 `enum=false` 模式(根盒查询,避免大模型全量枚举 10+ 分钟) +- **F1 测试后台残留修复**:`BatchQueueF1ProcessAutomationTests` 末尾等待批处理完全退出(`batch-queue-status` 新增 `isExecuting` 字段),修复 F1 跑完后全量测试 `BatchQueue_AddItems` 偶发失败 + +### 验证 + +- 集成测试 25/25 通过 + ## [0.18.2] - 2026-08-05 ### ✨ 功能变更 diff --git a/UnitTests/Integration/BatchQueueF1ProcessAutomationTests.cs b/UnitTests/Integration/BatchQueueF1ProcessAutomationTests.cs index 2003231..815db28 100644 --- a/UnitTests/Integration/BatchQueueF1ProcessAutomationTests.cs +++ b/UnitTests/Integration/BatchQueueF1ProcessAutomationTests.cs @@ -75,6 +75,19 @@ namespace NavisworksTransport.UnitTests.Integration status, "F1 批处理未在时限内完成。itemId=" + itemId + (string.IsNullOrEmpty(errorMessage) ? "" : ",错误: " + errorMessage)); + + // 等待后台批处理完全退出(避免后台任务残留影响后续测试的 isExecuting 断言) + DateTime exitDeadline = DateTime.UtcNow.AddSeconds(90); + while (DateTime.UtcNow < exitDeadline) + { + JObject statusResp = await client.BatchQueueStatusAsync(1).ConfigureAwait(false); + bool isExecuting = (bool?)statusResp["data"]?["isExecuting"] ?? false; + if (!isExecuting) + { + break; + } + await Task.Delay(2000).ConfigureAwait(false); + } } } } diff --git a/VERSION.md b/VERSION.md index eda4ce5..aad11e3 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,3 +1,3 @@ # 版本号 -0.18.2 +0.18.3 diff --git a/src/Core/SectionBoxExporter.cs b/src/Core/SectionBoxExporter.cs index 2f8193e..5eefeb8 100644 --- a/src/Core/SectionBoxExporter.cs +++ b/src/Core/SectionBoxExporter.cs @@ -266,6 +266,14 @@ namespace NavisworksTransport.Core /// 然后使用原来的兄弟节点算法计算隐藏节点 /// public SectionBoxTraversalResult GetObjectsAndHiddenItems(Document document, BoundingBox3D sectionBox) + { + return GetObjectsAndHiddenItems(document, sectionBox, prune: true); + } + + /// + /// 遍历模型树收集盒内对象(prune=false 时禁用组节点聚合盒剪枝,用于性能对比)。 + /// + public SectionBoxTraversalResult GetObjectsAndHiddenItems(Document document, BoundingBox3D sectionBox, bool prune) { var result = new SectionBoxTraversalResult(); var visibleSet = new HashSet(); // 收集需要保留的节点(对象+祖先) @@ -276,7 +284,7 @@ namespace NavisworksTransport.Core var rootItem = model.RootItem; if (rootItem != null) { - TraverseAndCollect(model.RootItem, sectionBox, result.ObjectsInBox, visibleSet); + TraverseAndCollect(model.RootItem, sectionBox, result.ObjectsInBox, visibleSet, prune); } } @@ -297,15 +305,31 @@ namespace NavisworksTransport.Core } /// - /// 递归遍历收集盒内对象和可见节点 + /// 递归遍历收集盒内对象和可见节点。 + /// 返回:本子树是否存在盒内对象(后序传播用——命中路径的祖先由父级逐层加入 visibleSet)。 /// - private void TraverseAndCollect(ModelItem item, BoundingBox3D sectionBox, - List objectsInBox, HashSet visibleSet) + private bool TraverseAndCollect(ModelItem item, BoundingBox3D sectionBox, + List objectsInBox, HashSet visibleSet, bool prune) { - if (item == null || item.IsHidden) return; + if (item == null || item.IsHidden) return false; + + bool hasGeometry = item.HasGeometry; + + // 子树剪枝:非几何节点(组节点)先查聚合包围盒(NW 内部缓存,O(1))。 + // 组节点盒与剖面盒不相交 → 其整棵子树内不可能存在盒内对象 → 跳过全部后代。 + // 收益:大型模型下远处楼层/区域一次调用剪掉整棵子树,省下所有后代的 + // Children/IsHidden/HasGeometry API 调用。 + if (prune && !hasGeometry) + { + var groupBox = item.BoundingBox(); + if (!BoundingBoxesIntersect(groupBox, sectionBox)) + { + return false; + } + } // 如果有几何体,检查是否在盒内 - if (item.HasGeometry) + if (hasGeometry) { var bbox = item.BoundingBox(); @@ -315,27 +339,34 @@ namespace NavisworksTransport.Core 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); - - // 将该对象及其所有祖先标记为可见 - foreach (var ancestor in item.AncestorsAndSelf) - { - visibleSet.Add(ancestor); - } + visibleSet.Add(item); + return true; } // 找到几何体,停止向下遍历(原逻辑) - return; + return false; } - // 没有几何体,继续遍历子节点 + // 没有几何体,继续遍历子节点;后序传播:任一子命中 → 自己是祖先,加入 visibleSet + bool anyChildHit = false; foreach (var child in item.Children) { - TraverseAndCollect(child, sectionBox, objectsInBox, visibleSet); + if (TraverseAndCollect(child, sectionBox, objectsInBox, visibleSet, prune)) + { + anyChildHit = true; + } } + + if (anyChildHit) + { + visibleSet.Add(item); + } + + return anyChildHit; } /// diff --git a/src/Core/Services/TestAutomationHttpService.cs b/src/Core/Services/TestAutomationHttpService.cs index 642d140..31d5210 100644 --- a/src/Core/Services/TestAutomationHttpService.cs +++ b/src/Core/Services/TestAutomationHttpService.cs @@ -231,6 +231,14 @@ namespace NavisworksTransport.Core.Services return; } + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && + string.Equals(request.Path, "/api/test/bbox-profiling", StringComparison.OrdinalIgnoreCase)) + { + object payload = InvokeOnUiThread(() => BuildBBoxProfilingPayload(request.Query)); + await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false); + return; + } + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) && string.Equals(request.Path, "/api/test/status", StringComparison.OrdinalIgnoreCase)) { @@ -447,6 +455,121 @@ namespace NavisworksTransport.Core.Services } } + /// + /// 【实验端点】NW 组节点/几何节点 BoundingBox 缓存行为分析。 + /// 验证:组节点聚合盒是否为 O(1) 缓存读取(用于剖面盒遍历子树剪枝)。 + /// + private object BuildBBoxProfilingPayload(Dictionary query) + { + Document doc = Autodesk.Navisworks.Api.Application.ActiveDocument; + if (doc == null || doc.IsClear) + { + return new { error = "无活动文档" }; + } + + bool enumOnly = ParseBooleanQuery(query, "enum", true); + var sw = new System.Diagnostics.Stopwatch(); + + // 根盒(不枚举,O(1))——大模型下全量枚举需数分钟,根盒/剪枝验证不需要枚举 + sw.Restart(); + var rootBox = doc.Models[0].RootItem.BoundingBox(); + long rootFirstMs = sw.ElapsedMilliseconds; + + sw.Restart(); + var rootBox2 = doc.Models[0].RootItem.BoundingBox(); + long rootSecondMs = sw.ElapsedMilliseconds; + + if (!enumOnly) + { + return new + { + enumSkipped = true, + rootFirstMs, + rootSecondMs, + rootBox = $"({rootBox.Min.X:F1},{rootBox.Min.Y:F1},{rootBox.Min.Z:F1})~({rootBox.Max.X:F1},{rootBox.Max.Y:F1},{rootBox.Max.Z:F1})" + }; + } + + // 全量枚举计时(RootItemDescendantsAndSelf 本身耗时) + sw.Restart(); + var allItems = doc.Models.SelectMany(m => m.RootItem?.DescendantsAndSelf ?? Enumerable.Empty()).ToList(); + long enumMs = sw.ElapsedMilliseconds; + + int totalCount = allItems.Count; + var groupNodes = allItems.Where(i => !i.HasGeometry && i.Children != null && i.Children.Count() > 0).Take(200).ToList(); + var geometryNodes = allItems.Where(i => i.HasGeometry).Take(200).ToList(); + + // 实验A(冷):不预先调用任何 BoundingBox,直接批量测组节点盒 + sw.Restart(); + long groupColdTotalMs = 0; + int groupColdCount = 0; + foreach (var g in groupNodes) + { + sw.Restart(); + var b = g.BoundingBox(); + groupColdTotalMs += sw.ElapsedMilliseconds; + groupColdCount++; + } + + // 实验A2(冷):几何节点盒 + sw.Restart(); + long geomColdTotalMs = 0; + int geomColdCount = 0; + foreach (var g in geometryNodes) + { + sw.Restart(); + var b = g.BoundingBox(); + geomColdTotalMs += sw.ElapsedMilliseconds; + geomColdCount++; + } + + // 实验B(热):根盒已在开头测过(缓存),直接批量测组节点盒 + sw.Restart(); + long groupHotTotalMs = 0; + int groupHotCount = 0; + foreach (var g in groupNodes) + { + sw.Restart(); + var b = g.BoundingBox(); + groupHotTotalMs += sw.ElapsedMilliseconds; + groupHotCount++; + } + + // 重复调用同一组节点(缓存特征:第二次起应接近 0) + ModelItem sampleGroup = groupNodes.FirstOrDefault(); + long repeatFirstMs = 0; + long repeatSecondMs = 0; + if (sampleGroup != null) + { + sw.Restart(); + var b1 = sampleGroup.BoundingBox(); + repeatFirstMs = sw.ElapsedMilliseconds; + + sw.Restart(); + var b2 = sampleGroup.BoundingBox(); + repeatSecondMs = sw.ElapsedMilliseconds; + } + + return new + { + enumMs, + totalCount, + groupSampleCount = groupColdCount, + geometrySampleCount = geomColdCount, + groupColdAvgMs = groupColdCount > 0 ? groupColdTotalMs / (double)groupColdCount : 0, + geomColdAvgMs = geomColdCount > 0 ? geomColdTotalMs / (double)geomColdCount : 0, + groupColdTotalMs, + geomColdTotalMs, + rootFirstMs, + rootSecondMs, + groupHotAvgMs = groupHotCount > 0 ? groupHotTotalMs / (double)groupHotCount : 0, + groupHotTotalMs, + repeatFirstMs, + repeatSecondMs, + rootBox = $"({rootBox.Min.X:F1},{rootBox.Min.Y:F1},{rootBox.Min.Z:F1})~({rootBox.Max.X:F1},{rootBox.Max.Y:F1},{rootBox.Max.Z:F1})" + }; + } + private object BuildStatusPayload() { Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument; @@ -761,12 +884,22 @@ namespace NavisworksTransport.Core.Services new Point3D(Math.Min(x1, x2), Math.Min(y1, y2), Math.Min(z1, z2)), new Point3D(Math.Max(x1, x2), Math.Max(y1, y2), Math.Max(z1, z2))); - var traversal = exporter.GetObjectsAndHiddenItems(document, bounds); + bool prune = ParseBooleanQuery(query, "prune", true); + var sw = new System.Diagnostics.Stopwatch(); + sw.Start(); + var traversal = exporter.GetObjectsAndHiddenItems(document, bounds, prune); + sw.Stop(); if (traversal.ObjectsInBox.Count == 0) { throw new InvalidOperationException("指定包围盒内没有找到对象"); } + var traversalResultPayload = new + { + traversalMs = sw.ElapsedMilliseconds, + prune + }; + string exportedPath = exporter.ExportToNwd(document, traversal, filePath); if (string.IsNullOrEmpty(exportedPath) || !File.Exists(exportedPath)) { @@ -781,7 +914,9 @@ namespace NavisworksTransport.Core.Services filePath = exportedPath, objectCount = traversal.ObjectsInBox.Count, hiddenNodeCount = traversal.ItemsToHide.Count, - fileSize = new FileInfo(exportedPath).Length + fileSize = new FileInfo(exportedPath).Length, + traversalMs = traversalResultPayload.traversalMs, + prune = traversalResultPayload.prune }; } @@ -968,6 +1103,8 @@ namespace NavisworksTransport.Core.Services return new { + isExecuting = manager.IsExecuting, + queueCount = manager.QueueCount, items = items.Select(item => new { itemId = item.Id,