NavisworksTransport/doc/design/2026/NavisworksAPI使用方法.md

140 KiB
Raw Blame History

Navisworks API 使用方法指南

基于真实官方示例的正确API用法总结

在线资源链接

  1. Autodesk Platform Services (APS) - 主要开发者门户:https://aps.autodesk.com/developer/overview/navisworks - 提供Navisworks集成工具和SDK
  2. AEC DevBlog - 官方开发博客:https://adndevblog.typepad.com/aec/navisworks/ - 包含2026版本新功能和技术文章
  3. Autodesk Developer Network - 开发者网络:https://www.autodesk.com/developer-network/app-store/navisworks - 提供开发资源和支持
  4. 非官方在线API文档 - ApiDocs.cohttps://apidocs.co/apps/navisworks/ - 目前仅覆盖2017-2018版本

参考示例来源

基于以下官方示例文件的真实API用法

  • C:\Users\Tellme\apps\NavisworksTransport\doc\navisworks_api\NET\examples\PlugIns\SearchComparisonPlugIn\SearchComparisonPlugIn.cs
  • C:\Users\Tellme\apps\NavisworksTransport\doc\navisworks_api\NET\examples\PlugIns\Examiner\Examiner.cs

1. 模型遍历和节点访问

1.1 正确的遍历方式

// ✅ 正确:获取所有模型项
IEnumerable<ModelItem> allItems = 
    Application.ActiveDocument.Models.RootItemDescendantsAndSelf;

// ✅ 正确:遍历特定模型的所有项
foreach (Model model in document.Models)
{
    foreach (ModelItem item in model.RootItem.DescendantsAndSelf)
    {
        // 处理每个模型项
    }
}

// ✅ 正确:只获取顶级节点
foreach (Model model in document.Models)
{
    foreach (ModelItem topLevelItem in model.RootItem.Children)
    {
        // 处理顶级节点
    }
}

1.2 获取子节点

// ✅ 正确:获取某个节点的所有后代
var childItems = selectedItem.DescendantsAndSelf.Where(x => x != selectedItem);

// ✅ 正确:只获取直接子节点
foreach (ModelItem child in parentItem.Children)
{
    // 处理直接子节点
}

1.3 遍历祖先节点

// ✅ 正确:向上遍历父节点链
var current = selectedItem.Parent;
while (current != null)
{
    // 处理祖先节点
    current = current.Parent;
}

2. 搜索和查询

2.1 使用LINQ查询

// ✅ 正确使用LINQ查询模型项
IEnumerable<ModelItem> results = 
    Application.ActiveDocument.Models.RootItemDescendantsAndSelf
    .Where(x => 
        x.HasGeometry && 
        !x.IsHidden &&
        x.ClassDisplayName.ToLower().Contains("wall"));

2.2 使用Search类

// ✅ 正确使用Search API
Search search = new Search();

// 添加搜索条件
search.SearchConditions.Add(
    SearchCondition.HasCategoryByName(PropertyCategoryNames.Geometry));
search.SearchConditions.Add(
    SearchCondition.HasPropertyByName(PropertyCategoryNames.Item, DataPropertyNames.ItemHidden)
    .EqualValue(VariantData.FromBoolean(false)));

// 设置搜索范围
search.Selection.SelectAll();
search.Locations = SearchLocations.DescendantsAndSelf;

// 执行搜索
ModelItemCollection results = search.FindAll(document, false);

2.3 迭代遍历(性能对比)

// ✅ 可用但性能较低:迭代方法
ModelItemCollection searchResults = new ModelItemCollection();
foreach (ModelItem modelItem in Application.ActiveDocument.Models.CreateCollectionFromRootItems().DescendantsAndSelf)
{
    if (modelItem.HasGeometry && !modelItem.IsHidden)
        searchResults.Add(modelItem);
}

3. 选择操作

3.1 操作当前选择

注意对选择进行操作,需要在主线程。

// ✅ 正确:获取当前选择
var currentSelection = document.CurrentSelection.SelectedItems;

// ✅ 正确:清空选择
document.CurrentSelection.Clear();

// ✅ 正确:添加到选择
document.CurrentSelection.Add(modelItem);

// ✅ 正确:复制集合到选择
document.CurrentSelection.CopyFrom(modelItems);

4. 可见性控制

4.1 隐藏和显示

// ✅ 正确:隐藏项目
ModelItemCollection itemsToHide = new ModelItemCollection();
itemsToHide.Add(modelItem);
document.Models.SetHidden(itemsToHide, true);

// ✅ 正确:显示项目
document.Models.SetHidden(itemsToHide, false);

// ✅ 正确:检查是否隐藏
if (modelItem.IsHidden)
{
    // 项目被隐藏
}

5. 文件导出

5.1 基本文件保存

// ✅ 正确保存NWD文件
document.SaveFile(filePath);

// ✅ 正确:指定版本保存
document.SaveFile(filePath, DocumentFileVersion.Current);

5.2 ExportToNwd API

// ✅ 正确使用ExportToNwd导出
var exportOptions = new NwdExportOptions();
exportOptions.ExcludeHiddenItems = true;  // 只导出可见项目
exportOptions.EmbedXrefs = false;
exportOptions.PreventObjectPropertyExport = false;

document.ExportToNwd(saveFilePath, exportOptions);

6. 性能最佳实践

6.1 避免的做法

// ❌ 错误使用不存在的API
// SearchCondition.HasAncestor(items) // 这个API不存在

// ❌ 错误:深度递归遍历
// void RecursiveTraversal(ModelItem item) // 大模型中可能导致堆栈溢出

6.2 推荐的做法

// ✅ 推荐使用内置的DescendantsAndSelf
var allDescendants = rootItem.DescendantsAndSelf;

// ✅ 推荐使用LINQ进行高效查询
var filteredItems = allItems.Where(x => x.HasGeometry);

// ✅ 推荐:批量操作而不是逐个操作
ModelItemCollection batchItems = new ModelItemCollection();
// 添加所有需要处理的项目
document.Models.SetHidden(batchItems, true); // 一次性操作

7. 完整示例:多选节点导出

public void ExportSelectedNodes(List<ModelItem> selectedItems, string filePath)
{
    var document = Application.ActiveDocument;
    var nodesToKeepVisible = new HashSet<ModelItem>();
    
    // 1. 收集需要保持可见的节点
    foreach (var selectedItem in selectedItems)
    {
        // 添加选中节点本身
        nodesToKeepVisible.Add(selectedItem);
        
        // 添加所有祖先节点
        var current = selectedItem.Parent;
        while (current != null)
        {
            nodesToKeepVisible.Add(current);
            current = current.Parent;
        }
        
        // 添加所有子节点(可选)
        var childItems = selectedItem.DescendantsAndSelf.Where(x => x != selectedItem);
        foreach (ModelItem child in childItems)
        {
            nodesToKeepVisible.Add(child);
        }
    }
    
    // 2. 收集顶级节点并决定隐藏哪些
    var itemsToHide = new ModelItemCollection();
    foreach (Model model in document.Models)
    {
        foreach (ModelItem topLevelItem in model.RootItem.Children)
        {
            bool shouldKeep = false;
            
            // 检查是否包含选中节点
            foreach (var selectedItem in selectedItems)
            {
                var current = selectedItem;
                while (current != null)
                {
                    if (current == topLevelItem)
                    {
                        shouldKeep = true;
                        break;
                    }
                    current = current.Parent;
                }
                if (shouldKeep) break;
            }
            
            if (!shouldKeep)
            {
                itemsToHide.Add(topLevelItem);
            }
        }
    }
    
    // 3. 执行隐藏和导出
    try
    {
        document.Models.SetHidden(itemsToHide, true);
        
        var exportOptions = new NwdExportOptions();
        exportOptions.ExcludeHiddenItems = true;
        document.ExportToNwd(filePath, exportOptions);
    }
    finally
    {
        // 4. 恢复可见性
        document.Models.SetHidden(itemsToHide, false);
    }
}

8. 线程安全 - 关键重要

8.1 Navisworks API 线程安全要求

核心原则:所有 Navisworks API 调用必须在主 UI 线程STA 线程)中执行

// ❌ 错误:在后台线程中调用 Navisworks API
await Task.Run(() =>
{
    var document = Application.ActiveDocument;  // 可能崩溃
    document.ExportToNwd(path, options);        // 会崩溃
});

// ✅ 正确:使用 Dispatcher.Invoke 确保主线程执行
await Task.Run(() =>
{
    System.Windows.Application.Current.Dispatcher.Invoke(() =>
    {
        var document = Application.ActiveDocument;
        document.ExportToNwd(path, options);  // 安全执行
    });
});

8.2 实际案例:分层导出修复

问题场景SimplifiedModelSplitterManager.ExportLayerToNwd 方法通过后台线程调用时崩溃

// ❌ 问题代码:导致崩溃
public bool ExportLayerToNwd(...)
{
    var document = NavisApplication.ActiveDocument;
    document.ExportToNwd(outputPath, exportOptions);  // 后台线程崩溃
}

修复方案:使用 Dispatcher.Invoke 包装所有 API 调用

// ✅ 修复代码:线程安全
public bool ExportLayerToNwd(...)
{
    bool exportResult = false;
    Exception exportException = null;
    
    // 确保在主线程中执行所有 Navisworks API 调用
    System.Windows.Application.Current.Dispatcher.Invoke(() =>
    {
        try
        {
            var document = NavisApplication.ActiveDocument;
            
            // 保存可见性状态
            var originalVisibilityState = SaveCurrentVisibilityState(document);
            
            try
            {
                // 隐藏不需要的项目
                var itemsToHide = GetItemsToHide(...);
                document.Models.SetHidden(itemsToHide, true);
                
                // 创建导出选项
                var exportOptions = new NwdExportOptions
                {
                    ExcludeHiddenItems = true,
                    EmbedXrefs = false,
                    PreventObjectPropertyExport = false
                };
                
                // 在主线程中安全执行导出
                document.ExportToNwd(outputPath, exportOptions);
                exportResult = true;
            }
            finally
            {
                // 恢复可见性状态
                RestoreVisibilityState(document, originalVisibilityState);
            }
        }
        catch (Exception ex)
        {
            exportException = ex;
        }
    });
    
    if (exportException != null)
        throw exportException;
        
    return exportResult;
}

8.3 线程安全检查和诊断

// ✅ 检查当前线程状态
var apartmentState = System.Threading.Thread.CurrentThread.GetApartmentState();
LogManager.Info($"当前线程状态: {apartmentState}");  // 应该是 STA

if (apartmentState != System.Threading.ApartmentState.STA)
{
    LogManager.Warning("警告不在STA线程中API调用可能失败");
}

// ✅ 验证是否在主线程中
bool isMainThread = System.Windows.Application.Current.Dispatcher.CheckAccess();
if (!isMainThread)
{
    LogManager.Warning("警告不在主线程中需要使用Dispatcher.Invoke");
}

8.4 常见线程安全问题和解决方案

问题场景 症状 解决方案
后台线程调用 API 程序崩溃,无错误信息 使用 Dispatcher.Invoke()
Command.ExecuteAsync() Task 中的 API 调用崩溃 在 Task 内部使用 Dispatcher
异步方法调用 API 间歇性崩溃 检查执行线程,确保主线程
Timer 中调用 API 定时器触发时崩溃 Timer 回调使用 Dispatcher

8.5 最佳实践模式

// ✅ 推荐模式:安全的异步 Navisworks API 调用
public async Task<bool> SafeNavisworksOperationAsync()
{
    // 1. 后台准备数据
    var preparedData = await Task.Run(() =>
    {
        // 在后台线程中进行数据准备(不涉及 Navisworks API
        return PrepareDataSafely();
    });
    
    // 2. 主线程执行 API 调用
    bool result = false;
    await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
    {
        // 所有 Navisworks API 调用都在主线程中
        var document = Application.ActiveDocument;
        result = document.SomeNavisworksOperation(preparedData);
    });
    
    return result;
}

// ✅ 推荐模式:批量 API 操作
public void BatchNavisworksOperations(List<ModelItem> items)
{
    System.Windows.Application.Current.Dispatcher.Invoke(() =>
    {
        var document = Application.ActiveDocument;
        
        // 批量操作,避免多次线程切换
        var itemCollection = new ModelItemCollection();
        foreach (var item in items)
        {
            itemCollection.Add(item);
        }
        
        // 一次性完成所有操作
        document.Models.SetHidden(itemCollection, true);
        document.CurrentSelection.CopyFrom(itemCollection);
    });
}

9. 常用属性和方法速查

ModelItem 常用属性

  • HasGeometry - 是否有几何体
  • IsHidden - 是否隐藏
  • IsRequired - 是否必需
  • IsInsert - 是否为插入对象
  • IsLayer - 是否为图层
  • DisplayName - 显示名称
  • ClassName - 类名
  • ClassDisplayName - 类显示名称
  • Parent - 父节点
  • Children - 子节点集合
  • DescendantsAndSelf - 所有后代节点(包括自己)

Document 常用方法

  • SaveFile(string path) - 保存文件
  • ExportToNwd(string path, NwdExportOptions options) - 导出NWD
  • CurrentSelection - 当前选择
  • Models - 模型集合

Models 常用方法

  • SetHidden(ModelItemCollection items, bool hidden) - 设置隐藏状态
  • SetRequired(ModelItemCollection items, bool required) - 设置必需状态
  • RootItemDescendantsAndSelf - 所有根项目的后代

10. 错误避免指南

  1. 线程安全是第一要务:所有 Navisworks API 调用必须在主 UI 线程中执行
  2. 不要使用不存在的API:如 SearchCondition.HasAncestor
  3. 避免深度递归:使用内置的 DescendantsAndSelf 代替手写递归
  4. 批量操作:使用 ModelItemCollection 进行批量设置,而不是逐个操作
  5. 正确的命名空间:确保引用 using Autodesk.Navisworks.Api;
  6. 异常处理文件操作和API调用要适当处理异常
  7. 资源清理:隐藏操作后要恢复原始状态
  8. 线程状态检查在关键操作前验证线程状态STA
  9. Dispatcher 模式:后台线程中需要调用 API 时,始终使用 Dispatcher.Invoke

11. Transform 变换操作

11.1 Transform 相关 API 概念

核心概念

  • ModelItem.Transform - 返回设计文件中的原始变换,只读属性不反映override后的状态
  • OverridePermanentTransform() - 应用增量变换相对于原始Transform累积
  • ResetPermanentTransform() - 清除所有增量变换,恢复到设计文件原始位置
  • ModelItem.BoundingBox() - 返回当前实际显示的包围盒反映override效果

⚠️ 关键理解

  1. ModelItem.Transform 永远返回原始值,即使通过 OverridePermanentTransform 改变了物体位置
  2. Override 信息存储在别处,不会修改 ModelItem.Transform 属性
  3. 要获取实际位置,使用 BoundingBox().Center它反映override后的实际位置

11.2 Transform 操作的正确用法

// ✅ 获取物体的原始Transform设计文件中的位置
Transform3D originalTransform = modelItem.Transform;

// ✅ 应用增量变换(累积变换)
var doc = Application.ActiveDocument;
var modelItems = new ModelItemCollection { modelItem };
doc.Models.OverridePermanentTransform(modelItems, newTransform, false);

// ✅ 重置到原始位置(清除所有增量变换)
doc.Models.ResetPermanentTransform(modelItems);

11.3 Transform 操作的关键区别

API方法 作用 使用场景 注意事项
ModelItem.Transform 获取原始变换 记录物体初始位置 只读属性,返回设计文件位置
OverridePermanentTransform() 应用增量变换 动画中移动物体 与现有变换累积,不是绝对位置
ResetPermanentTransform() 重置到原始位置 清除所有移动,恢复初始状态 忽略所有之前的变换

11.4 实际应用案例

案例1动画系统中的Transform管理

// 动画开始时记录原始位置
private Transform3D _originalTransform;

public void StartAnimation(ModelItem animatedObject)
{
    // 记录原始Transform
    _originalTransform = animatedObject.Transform;
    
    // 移动到路径起点(增量变换)
    var startTransform = Transform3D.CreateTranslation(startPosition);
    var modelItems = new ModelItemCollection { animatedObject };
    doc.Models.OverridePermanentTransform(modelItems, startTransform, false);
}

public void ResetAnimation()
{
    // 动画结束后使用原始Transform恢复位置
    var modelItems = new ModelItemCollection { _animatedObject };
    doc.Models.OverridePermanentTransform(modelItems, _originalTransform, false);
}

案例2用户手动位置恢复

public void RestoreToOriginalPosition(ModelItem selectedObject)
{
    // 不需要记录Transform直接重置到设计文件原始位置
    var doc = Application.ActiveDocument;
    var modelItems = new ModelItemCollection { selectedObject };
    
    // 清除所有增量变换,恢复到设计文件原始位置
    doc.Models.ResetPermanentTransform(modelItems);
}

11.5 常见Transform问题和解决方案

问题1获取不到实际位置

// ❌ 错误:以为 Transform 反映当前位置
var transform = item.Transform;  
// 问题这永远返回原始Transform即使物体已被移动

// ✅ 正确:使用 BoundingBox 获取实际位置
var actualCenter = item.BoundingBox().Center;  // 反映override后的实际位置

问题2动画结束后位置不准确

// ✅ 动画系统应该记录原始Transform并使用增量恢复
private Transform3D _originalTransform;

// 动画开始时
_originalTransform = animatedObject.Transform;

// 动画结束时恢复
doc.Models.OverridePermanentTransform(modelItems, _originalTransform, false);

问题3记录Transform但不使用

// ❌ 不必要记录Transform但使用Reset
private Transform3D _originalTransform;
_originalTransform = selectedItem.Transform;  // 记录了但不使用
doc.Models.ResetPermanentTransform(modelItems);  // 直接重置

// ✅ 简化:直接重置,无需记录
doc.Models.ResetPermanentTransform(modelItems);

11.6 Transform 最佳实践

  1. 选择合适的恢复方式

    • 动画系统:使用 OverridePermanentTransform + 原始Transform
    • 用户操作:使用 ResetPermanentTransform 直接重置
  2. 避免不必要的Transform记录

    • 如果只需要恢复到设计文件原始位置,使用 ResetPermanentTransform
    • 只有需要恢复到特定中间状态时才记录Transform
  3. 理解增量vs绝对变换

    • OverridePermanentTransform 是增量的,会与现有变换叠加
    • ResetPermanentTransform 是绝对的,清除所有变换
  4. 线程安全

    • 所有Transform操作都必须在主UI线程中执行
    • 使用 Dispatcher.Invoke 确保线程安全

11.7 旋转操作的关键限制和解决方案 ⚠️ 重要

基于实际测试验证的关键发现2025-12-15

11.7.1 旋转中心的API限制

⚠️ 核心限制Navisworks API的旋转总是绕世界原点(0,0,0)进行

// ❌ 错误理解:以为旋转绕物体中心
var rotation = new Transform3D(new Rotation3D(new UnitVector3D(0, 0, 1), angle));
doc.Models.OverridePermanentTransform(modelItems, rotation, false);
// 实际效果:物体绕世界原点(0,0,0)"公转",不是绕自己"自转"

// 🔍 实际测试验证:
// 物体在 (-2.499, -1.640, 0.500) 位置
// 旋转45度后移动到 (-0.608, -2.927, 0.500)
// 验证公式x' = x*cos(45°) - y*sin(45°) = -0.607 ✓
//          y' = x*sin(45°) + y*cos(45°) = -2.927 ✓
// 证明:旋转中心是世界原点(0,0,0),不是物体中心

验证代码

// ✅ 测试代码:证明旋转绕世界原点
var initialCenter = item.BoundingBox().Center;  // (-2.499, -1.640, 0.500)

// 应用45度旋转
var rotation = new Transform3D(new Rotation3D(new UnitVector3D(0, 0, 1), Math.PI/4));
doc.Models.OverridePermanentTransform(modelItems, rotation, false);

var afterCenter = item.BoundingBox().Center;    // (-0.608, -2.927, 0.500)

// 计算期望位置(绕原点旋转)
double cos45 = Math.Cos(Math.PI/4);
double sin45 = Math.Sin(Math.PI/4);
double expectedX = initialCenter.X * cos45 - initialCenter.Y * sin45;  // -0.607
double expectedY = initialCenter.X * sin45 + initialCenter.Y * cos45;  // -2.927

// 验证:实际位置 = 期望位置(绕原点旋转)✓

11.7.2 Transform3DComponents 的行为

关键理解:Transform3DComponents.Combine() 的变换顺序

// Transform3DComponents.Combine() 应用顺序:
// 1. Scale缩放
// 2. Rotation旋转绕原点
// 3. Translation平移

// ❌ 错误直接设置rotation和translation
var components = identity.Factor();
components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw);
components.Translation = deltaPos;  // 平移在旋转之后应用
var transform = components.Combine();

// 问题:物体先绕原点旋转(产生位置偏移),然后平移
// 结果:物体"公转"到错误位置

11.7.3 正确实现"绕物体中心旋转"

解决方案:手动计算旋转导致的位置偏移并补偿

// ✅ 正确方法:计算补偿平移量
private void UpdateObjectPosition(Point3D newPosition, double newYaw)
{
    var doc = Application.ActiveDocument;
    var modelItems = new ModelItemCollection { _animatedObject };
    
    // 计算旋转和平移增量
    var deltaPos = new Vector3D(
        newPosition.X - _currentPosition.X,
        newPosition.Y - _currentPosition.Y,
        newPosition.Z - _currentPosition.Z
    );
    
    Transform3D incrementalTransform;
    
    if (!double.IsNaN(newYaw))
    {
        double deltaYaw = newYaw - _currentYaw;
        
        // 🎯 关键:计算绕当前位置旋转的等效变换
        // 1. 如果绕原点旋转deltaYaw当前位置会移到哪里
        double cos = Math.Cos(deltaYaw);
        double sin = Math.Sin(deltaYaw);
        double rotatedX = _currentPosition.X * cos - _currentPosition.Y * sin;
        double rotatedY = _currentPosition.X * sin + _currentPosition.Y * cos;
        
        // 2. 我们希望物体绕自己旋转位置移动到newPosition
        // 所以需要的平移 = newPosition - (旋转后的位置)
        var compensatedTranslation = new Vector3D(
            newPosition.X - rotatedX,      // 补偿X方向的偏移
            newPosition.Y - rotatedY,      // 补偿Y方向的偏移
            newPosition.Z - _currentPosition.Z  // Z保持增量
        );
        
        // 3. 组合:先旋转(绕原点),再平移(补偿+目标位置)
        var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0));
        var components = identity.Factor();
        components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw);
        components.Translation = compensatedTranslation;  // 关键:使用补偿后的平移
        
        incrementalTransform = components.Combine();
        _currentYaw = newYaw;
    }
    else
    {
        // 纯平移:直接使用增量
        incrementalTransform = Transform3D.CreateTranslation(deltaPos);
    }
    
    // 应用增量变换
    doc.Models.OverridePermanentTransform(modelItems, incrementalTransform, false);
    _currentPosition = newPosition;
}

原理说明

API限制
  旋转 → 物体绕(0,0,0)旋转 → 位置从P1偏移到P2
  
我们需要的效果:
  旋转 → 物体绕自己旋转 → 位置从P1移动到P_target
  
解决方案:
  补偿平移 = P_target - P2
  最终变换 = Rotation(deltaYaw) + Translation(P_target - P2)
  
结果:
  物体先绕原点旋转到P2然后平移到P_target
  看起来像是绕自己旋转并移动到目标位置

11.7.4 初始化问题

⚠️ 重要初始化yaw必须与第一帧匹配

// ❌ 错误初始化为0
_currentYaw = 0.0;
// 第一帧调用UpdateObjectPosition时
// deltaYaw = firstFrame.YawRadians - 0.0  // 产生大的旋转增量
// 导致物体从起点"公转"飞走

// ✅ 正确初始化为第一帧的yaw
if (_animationFrames != null && _animationFrames.Count > 0)
{
    _currentYaw = _animationFrames[0].YawRadians;  // 使deltaYaw=0
    
    // 第一次调用UpdateObjectPosition
    var firstFrame = _animationFrames[0];
    UpdateObjectPosition(firstFrame.Position, firstFrame.YawRadians);
    // 此时deltaYaw = firstFrame.YawRadians - firstFrame.YawRadians = 0
    // 结果:只有平移,没有旋转偏移
}

11.7.5 相关API限制说明

Autodesk官方论坛已确认的限制Issue NW-53280

  • 无法设置旋转中心点API不提供指定旋转中心的方法
  • UI的Override Transform功能:也是通过计算补偿实现的
  • 建议的解决方案手动计算T(center) × R × T(-center)的等效变换

11.7.6 旋转操作最佳实践

场景 方法 注意事项
简单旋转(原地) 使用位置补偿公式 必须计算旋转导致的偏移
旋转+移动 组合补偿平移和目标平移 理解Combine()的变换顺序
动画初始化 _currentYaw = firstFrame.YawRadians 避免第一帧产生旋转增量
调试验证 测试物体远离原点的情况 原点附近可能掩盖问题

11.7.7 关键原则移动物体前必须先重置到CAD位置 ⚠️ 重要

问题场景 当物体已经被移动过(如动画结束在终点位置),再次移动时如果直接从当前位置计算增量,会导致错误的结果。

原因 OverridePermanentTransform 的增量是相对于CAD原始位置的,不是相对于当前位置。

错误做法

// 物体当前在终点位置,但我们要移动到另一个位置
var currentPos = item.BoundingBox().Center;  // 终点位置
var deltaPos = new Vector3D(
    targetPos.X - currentPos.X,  // 从终点计算增量 - 错误!
    targetPos.Y - currentPos.Y,
    targetPos.Z - currentPos.Z
);
var transform = Transform3D.CreateTranslation(deltaPos);
doc.Models.OverridePermanentTransform(modelItems, transform, false);
// 结果物体会移动到错误位置因为增量是相对于CAD位置的

正确做法

// 1. 先重置到CAD原始位置
doc.Models.ResetPermanentTransform(modelItems);

// 2. 从CAD原始位置计算到目标位置的增量
var originalBounds = item.BoundingBox();
var originalPos = new Point3D(
    originalBounds.Center.X,
    originalBounds.Center.Y,
    originalBounds.Min.Z
);
var deltaPos = new Vector3D(
    targetPos.X - originalPos.X,  // 从CAD位置计算增量 - 正确!
    targetPos.Y - originalPos.Y,
    targetPos.Z - originalPos.Z
);

// 3. 应用变换
var transform = Transform3D.CreateTranslation(deltaPos);
doc.Models.OverridePermanentTransform(modelItems, transform, false);

使用场景

  • 碰撞报告还原物体到碰撞位置
  • 手动指定物体位置
  • 任何需要精确控制物体最终位置的操作

最佳实践

/// <summary>
/// 将物体移动到指定位置和朝向先回到CAD原始位置
/// </summary>
public static void MoveItemToPositionAndYaw(ModelItem item, Point3D targetPosition, double targetYaw)
{
    var doc = Application.ActiveDocument;
    var modelItems = new ModelItemCollection { item };

    // 🔥 关键先回到CAD原始位置
    doc.Models.ResetPermanentTransform(modelItems);

    // 获取CAD原始状态
    var originalBounds = item.BoundingBox();
    var originalGroundPos = new Point3D(
        originalBounds.Center.X,
        originalBounds.Center.Y,
        originalBounds.Min.Z
    );
    var originalYaw = GetYawFromTransform(item.Transform);

    // 计算从CAD位置到目标位置的增量
    var deltaPos = new Vector3D(
        targetPosition.X - originalGroundPos.X,
        targetPosition.Y - originalGroundPos.Y,
        targetPosition.Z - originalGroundPos.Z
    );
    double deltaYaw = targetYaw - originalYaw;

    // 应用增量变换(包含旋转补偿)
    Transform3D transform;
    if (Math.Abs(deltaYaw) > 0.001)
    {
        // 计算旋转补偿
        double cos = Math.Cos(deltaYaw);
        double sin = Math.Sin(deltaYaw);
        double rotatedX = originalGroundPos.X * cos - originalGroundPos.Y * sin;
        double rotatedY = originalGroundPos.X * sin + originalGroundPos.Y * cos;

        var compensatedTranslation = new Vector3D(
            targetPosition.X - rotatedX,
            targetPosition.Y - rotatedY,
            deltaPos.Z
        );

        var identity = Transform3D.CreateTranslation(new Vector3D(0, 0, 0));
        var components = identity.Factor();
        components.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), deltaYaw);
        components.Translation = compensatedTranslation;
        transform = components.Combine();
    }
    else
    {
        transform = Transform3D.CreateTranslation(deltaPos);
    }

    doc.Models.OverridePermanentTransform(modelItems, transform, false);
}

调试技巧

// ✅ 测试旋转中心的方法
// 1. 将物体移动到远离原点的位置(如(-5, -5, 0)
// 2. 应用旋转
// 3. 检查物体是否"公转"(位置大幅移动)还是"自转"(位置基本不变)
// 4. 如果发现"公转",说明没有正确补偿

// ✅ 验证补偿计算的公式
double expectedX_afterRotation = currentX * cos(angle) - currentY * sin(angle);
double expectedY_afterRotation = currentX * sin(angle) + currentY * cos(angle);
var compensationX = targetX - expectedX_afterRotation;
var compensationY = targetY - expectedY_afterRotation;

LogManager.Debug($"旋转前: ({currentX}, {currentY})");
LogManager.Debug($"绕原点旋转后: ({expectedX_afterRotation}, {expectedY_afterRotation})");
LogManager.Debug($"目标位置: ({targetX}, {targetY})");
LogManager.Debug($"需要补偿: ({compensationX}, {compensationY})");

12. Item属性和自定义属性访问

基于官方示例的正确属性访问方法总结。

12.1 NET API 属性访问方法

基础属性访问(基于 Examiner.cs

// ✅ 通过PropertyCategories查找特定分类
var category = item.PropertyCategories.FindCategoryByDisplayName("Material");

// ✅ 访问基础属性
string displayName = item.DisplayName;
string className = item.ClassName;  
string classDisplayName = item.ClassDisplayName;
bool hasGeometry = item.HasGeometry;
bool isHidden = item.IsHidden;
bool isRequired = item.IsRequired;

// ✅ 通过LINQ查询特定属性的项目
IEnumerable<ModelItem> itemsWithMaterial = 
    Application.ActiveDocument.Models.RootItemDescendantsAndSelf
    .Where(x => 
        x.PropertyCategories.FindCategoryByDisplayName("Material") != null);

高级属性搜索(基于 SearchComparisonPlugIn.cs

// ✅ 使用预定义属性分类和属性名进行搜索
Search search = new Search();

// 搜索有几何体的项目
search.SearchConditions.Add(
    SearchCondition.HasCategoryByName(PropertyCategoryNames.Geometry));

// 搜索非隐藏的项目
search.SearchConditions.Add(
    SearchCondition.HasPropertyByName(PropertyCategoryNames.Item, DataPropertyNames.ItemHidden)
    .EqualValue(VariantData.FromBoolean(false)));

// 设置搜索范围并执行
search.Selection.SelectAll();
search.Locations = SearchLocations.DescendantsAndSelf;
ModelItemCollection results = search.FindAll(document, false);

属性分类和属性名常量

// ✅ 使用预定义常量访问标准属性
// PropertyCategoryNames 包含:
// - PropertyCategoryNames.Item (项目属性)
// - PropertyCategoryNames.Geometry (几何属性)
// - PropertyCategoryNames.Material (材质属性)

// DataPropertyNames 包含:
// - DataPropertyNames.ItemHidden (隐藏状态)
// - DataPropertyNames.ItemRequired (必需状态)
// 等等...

12.2 COM API 属性访问方法(基于 AutoUserPropsExample.cs

获取和遍历属性

// ✅ 获取选中对象的属性节点
InwOpSelection2 selection = m_state.CurrentSelection as InwOpSelection2;
if (selection.Paths().Count > 0)
{
    InwGUIPropertyNode2 propertyNode = 
        m_state.GetGUIPropertyNode(selection.Paths()[1], true) as InwGUIPropertyNode2;
    
    // 遍历所有属性分类
    foreach (InwGUIAttribute2 guiAttribute in propertyNode.GUIAttributes())
    {
        Console.WriteLine($"分类: {guiAttribute.ClassName}");
        Console.WriteLine($"显示名: {guiAttribute.ClassUserName}");
        Console.WriteLine($"用户自定义: {guiAttribute.UserDefined}");
        
        // 遍历分类中的所有属性
        foreach (InwOaProperty property in guiAttribute.Properties())
        {
            string propertyName = property.name;        // 内部名称
            string displayName = property.UserName;     // 显示名称
            string value = property.value;              // 属性值
            
            Console.WriteLine($"  {displayName}({propertyName}) = {value}");
        }
    }
}

添加自定义属性

// ✅ 创建新的自定义属性
private void AddCustomProperty(string categoryName, string internalName, string value)
{
    InwOpSelection2 selection = m_state.CurrentSelection as InwOpSelection2;
    if (selection.Paths().Count > 0)
    {
        InwGUIPropertyNode2 propertyNode = 
            m_state.GetGUIPropertyNode(selection.Paths()[1], true) as InwGUIPropertyNode2;
        
        // 创建属性容器
        InwOaPropertyVec propertyVector = 
            m_state.ObjectFactory(nwEObjectType.eObjectType_nwOaPropertyVec);
        
        // 创建单个属性
        InwOaProperty property = 
            m_state.ObjectFactory(nwEObjectType.eObjectType_nwOaProperty);
        property.name = "CustomProperty1";          // 内部名称
        property.UserName = "自定义属性1";          // 显示名称
        property.value = value;                     // 属性值
        
        // 添加到容器
        propertyVector.Properties().Add(property);
        
        // 设置到对象上
        propertyNode.SetUserDefined(0, categoryName, internalName, propertyVector);
    }
}

// ✅ 使用示例
AddCustomProperty("自定义分类", "Custom_Category", "自定义值");

修改现有自定义属性

// ✅ 正确的修改方法基于实际修复经验和官方AutoUserPropsExample示例
private void UpdateCustomProperty(string categoryName, string internalName, string newValue)
{
    InwOpSelection2 selection = m_state.CurrentSelection as InwOpSelection2;
    if (selection.Paths().Count > 0)
    {
        InwGUIPropertyNode2 propertyNode = 
            m_state.GetGUIPropertyNode(selection.Paths()[1], true) as InwGUIPropertyNode2;
        
        // 🔍 关键第一步:查找现有属性分类的正确索引
        int existingIndex = GetFloorAttributeIndex(propertyNode, categoryName);
        
        // 创建新的属性内容
        InwOaPropertyVec propertyVector = 
            m_state.ObjectFactory(nwEObjectType.eObjectType_nwOaPropertyVec);
        
        InwOaProperty property = 
            m_state.ObjectFactory(nwEObjectType.eObjectType_nwOaProperty);
        property.name = "Floor_Level";
        property.UserName = "楼层";
        property.value = newValue;
        
        propertyVector.Properties().Add(property);
        
        if (existingIndex >= 0)
        {
            // 🎯 存在属性时:使用正确的索引进行更新
            // 注意SetUserDefined的索引是从1开始的
            int updateIndex = existingIndex + 1;
            propertyNode.SetUserDefined(updateIndex, categoryName, internalName, propertyVector);
        }
        else
        {
            // 🆕 不存在属性时使用索引0创建新属性分类
            propertyNode.SetUserDefined(0, categoryName, internalName, propertyVector);
        }
    }
}

// ✅ 查找现有属性分类索引的正确方法
private int GetFloorAttributeIndex(InwGUIPropertyNode2 propertyNode, string categoryName)
{
    int userDefinedIndex = 0; // 用户定义属性的索引计数器
    
    foreach (InwGUIAttribute2 attribute in propertyNode.GUIAttributes())
    {
        if (attribute.UserDefined)
        {
            // ⚠️ 关键修复只需要匹配ClassUserName不需要匹配ClassName
            // ClassName是系统生成的如"LcOaPropOverrideCat"),不是我们控制的
            if (attribute.ClassUserName == categoryName)
            {
                return userDefinedIndex; // 返回在用户定义属性中的索引位置
            }
            userDefinedIndex++;
        }
    }
    return -1; // 未找到返回-1
}

// ✅ 使用示例 - 正确的更新方式
// 第一次设置:创建新属性
UpdateCustomProperty("分层信息", "Floor_Category", "F1");
// 第二次设置:找到并更新现有属性
UpdateCustomProperty("分层信息", "Floor_Category", "F2");
// 第三次设置:继续更新同一个属性
UpdateCustomProperty("分层信息", "Floor_Category", "F3");

关键要点(基于实际修复经验):

  • 动态索引查找:不能使用硬编码索引,必须动态查找现有属性的位置
  • 只匹配ClassUserNameClassName是系统生成的标识符(如LcOaPropOverrideCat),我们无法控制
  • 索引转换规则查找返回的是0基索引SetUserDefined使用的是1基索引
  • 创建vs更新index=0表示创建新属性index>0表示更新指定位置的属性

删除自定义属性

// ✅ 删除指定的自定义属性分类
private void RemoveCustomProperty()
{
    InwOpSelection2 selection = m_state.CurrentSelection as InwOpSelection2;
    if (selection.Paths().Count > 0)
    {
        InwGUIPropertyNode2 propertyNode = 
            m_state.GetGUIPropertyNode(selection.Paths()[1], true) as InwGUIPropertyNode2;
        
        // 删除索引为0的用户自定义属性分类
        propertyNode.RemoveUserDefined(0);
    }
}

12.3 两种API的选择建议

操作类型 推荐API 理由
搜索和过滤 NET API LINQ查询更灵活性能更好
读取标准属性 NET API 类型安全,代码简洁
添加/修改自定义属性 COM API 提供完整的属性操作能力
删除自定义属性 COM API NET API不支持属性删除
批量属性操作 NET API 支持ModelItemCollection批量操作

12.4 属性操作最佳实践

// ✅ 推荐模式结合两种API的优势
public void ProcessItemsWithCustomProperties()
{
    // 1. 使用NET API进行搜索和过滤
    var itemsWithGeometry = Application.ActiveDocument.Models.RootItemDescendantsAndSelf
        .Where(item => item.HasGeometry && !item.IsHidden)
        .ToList();
    
    // 2. 对每个项目使用COM API添加自定义属性
    foreach (ModelItem item in itemsWithGeometry)
    {
        // 选中当前项目
        Application.ActiveDocument.CurrentSelection.Clear();
        Application.ActiveDocument.CurrentSelection.Add(item);
        
        // 使用COM API添加自定义属性
        AddCustomProperty("物流信息", "Logistics_Info", $"处理时间: {DateTime.Now}");
    }
}

12.5 常见属性操作示例

// ✅ 检查项目是否有特定属性分类
public bool HasPropertyCategory(ModelItem item, string categoryName)
{
    return item.PropertyCategories.FindCategoryByDisplayName(categoryName) != null;
}

// ✅ 获取项目的所有属性信息(用于调试)
public string GetItemPropertyInfo(ModelItem item)
{
    var sb = new StringBuilder();
    sb.AppendLine($"项目: {item.DisplayName}");
    
    foreach (PropertyCategory category in item.PropertyCategories)
    {
        sb.AppendLine($"  分类: {category.DisplayName}");
        foreach (DataProperty property in category.Properties)
        {
            sb.AppendLine($"    {property.DisplayName}: {property.Value}");
        }
    }
    
    return sb.ToString();
}

// ✅ 基于属性值进行复杂搜索
public List<ModelItem> FindItemsByPropertyValue(string categoryName, string propertyName, string searchValue)
{
    return Application.ActiveDocument.Models.RootItemDescendantsAndSelf
        .Where(item => 
        {
            var category = item.PropertyCategories.FindCategoryByDisplayName(categoryName);
            if (category == null) return false;
            
            var property = category.Properties.FirstOrDefault(p => p.DisplayName == propertyName);
            return property != null && property.Value.ToString().Contains(searchValue);
        })
        .ToList();
}

12.6 Item属性的可编辑性分析

根据官方示例和Navisworks API设计大部分标准Item属性是只读的,但有少数属性可以编辑

可编辑的Item属性

1. 可见性和状态属性通过Models API编辑

// ✅ 可以修改:隐藏状态
document.Models.SetHidden(modelItemCollection, true);

// ✅ 可以修改:必需状态  
document.Models.SetRequired(modelItemCollection, true);

// ✅ 可以修改:颜色覆盖
document.Models.OverridePermanentColor(modelItemCollection, Color.Red);

// ✅ 可以修改:透明度覆盖
document.Models.OverridePermanentTransparency(modelItemCollection, 0.5);

// ✅ 可以修改Transform变换
document.Models.OverridePermanentTransform(modelItemCollection, transform, false);

2. 基于官方Examiner.cs示例的确认

// 官方示例中的这些操作证实了这些属性是可修改的
document.Models.SetRequired(items, (required == SearchForm.ChangeDecision.Yes));
document.Models.SetHidden(items, (hidden == SearchForm.ChangeDecision.Yes));
document.Models.OverridePermanentColor(items, overrideColor);
document.Models.OverridePermanentTransparency(items, overrideTransparencyValue);

只读的Item属性

1. 基础标识属性(只读)

// ❌ 只读无法修改由原始CAD文件决定
item.DisplayName        // 显示名称
item.ClassName          // 类名
item.ClassDisplayName   // 类显示名称
item.HasGeometry        // 几何体标志
item.IsInsert          // 插入对象标志
item.IsLayer           // 图层标志

2. 结构关系属性(只读)

// ❌ 只读结构关系由模型文件决定无法通过API修改
item.Parent            // 父节点
item.Children          // 子节点
item.Ancestors         // 祖先节点
item.Descendants       // 后代节点

3. 几何和材质属性(只读)

// ❌ 只读由原始CAD文件决定
item.Geometry          // 几何信息
item.BoundingBox()     // 包围盒
item.PropertyCategories // 标准属性分类但可通过COM API添加自定义分类

特殊情况:自定义属性完全可编辑

通过COM API可以完全控制自定义属性

// ✅ 完全可编辑:自定义属性
// 添加新的自定义属性
propertyNode.SetUserDefined(index, categoryName, internalName, propertyVector);

// 修改现有自定义属性(重新设置)
propertyNode.SetUserDefined(index, categoryName, internalName, newPropertyVector);

// 删除自定义属性
propertyNode.RemoveUserDefined(index);

物流插件的实际应用策略

1. 使用可编辑的标准属性进行状态管理

// 物流分类可见性管理
document.Models.SetHidden(obstacleItems, true);              // 隐藏障碍物
document.Models.SetHidden(loadingZoneItems, false);          // 显示装卸区

// 路径可视化
document.Models.OverridePermanentColor(pathItems, pathColor);        // 路径颜色标记
document.Models.OverridePermanentColor(selectedItems, highlightColor); // 选中高亮

// 动画和移动
document.Models.OverridePermanentTransform(movableItems, newTransform, false); // 物体移动动画

2. 使用自定义属性存储业务数据

// 存储物流分类信息
AddCustomProperty("物流信息", "Logistics_Category", "装卸区");
AddCustomProperty("物流信息", "Access_Level", "高优先级");
AddCustomProperty("物流信息", "Capacity", "1000kg");

// 存储路径规划数据
AddCustomProperty("路径信息", "Path_ID", "Path_001");
AddCustomProperty("路径信息", "Path_Length", "15.5m");
AddCustomProperty("路径信息", "Travel_Time", "120s");

// 存储碰撞检测结果
AddCustomProperty("碰撞信息", "Collision_Status", "Safe");
AddCustomProperty("碰撞信息", "Last_Check", DateTime.Now.ToString());

3. 只读属性用于查询和智能分析

// 基于只读属性进行智能空间分析
var suitableForStorage = items.Where(x => 
    x.HasGeometry && 
    x.ClassDisplayName.Contains("Room") &&
    !x.IsHidden &&
    x.BoundingBox().Max.Z > 3.0); // 高度足够的房间

// 基于结构关系进行逻辑分组
var floorItems = selectedFloor.DescendantsAndSelf
    .Where(x => x.HasGeometry);

4. 综合应用示例:物流节点标记

public void MarkAsLogisticsNode(ModelItem item, string category, Dictionary<string, string> properties)
{
    var itemCollection = new ModelItemCollection { item };
    
    // 1. 使用标准可编辑属性设置可视化
    Color categoryColor = GetCategoryColor(category);
    document.Models.OverridePermanentColor(itemCollection, categoryColor);
    document.Models.SetRequired(itemCollection, true); // 标记为重要
    
    // 2. 使用自定义属性存储详细信息
    document.CurrentSelection.Clear();
    document.CurrentSelection.Add(item);
    
    AddCustomProperty("物流分类", "Category", category);
    foreach (var kvp in properties)
    {
        AddCustomProperty("物流属性", kvp.Key, kvp.Value);
    }
    
    // 3. 基于只读属性进行验证
    if (!item.HasGeometry)
    {
        LogManager.Warning($"警告:{item.DisplayName} 没有几何体,可能不适合作为物流节点");
    }
}

属性编辑最佳实践

用途 推荐方法 API类型 特点
状态标记 SetHidden, SetRequired NET API 影响显示和选择
可视化 OverridePermanentColor NET API 临时视觉效果
空间变换 OverridePermanentTransform NET API 支持动画
业务数据 SetUserDefined COM API 持久化存储
查询分析 只读属性 + LINQ NET API 高性能筛选

12.7 属性操作注意事项

  1. 线程安全所有属性操作都必须在主UI线程中执行
  2. 选择状态COM API属性操作需要先选中目标对象
  3. 性能考虑大批量属性读取时NET API性能更佳
  4. 属性持久化自定义属性会保存在NWD文件中标准属性覆盖是临时的
  5. 属性索引COM API中的用户自定义属性使用索引管理
  6. 错误处理属性不存在时API会返回null需要检查
  7. 属性类型限制只读属性无法通过API修改只能通过可编辑API间接影响
  8. 覆盖vs原始OverridePermanent系列方法是覆盖原始属性可以恢复

12.8 COM API 自定义属性重要修复经验

基于实际生产环境中发现的问题和修复经验这里记录COM API操作自定义属性时的关键要点。

🚨 常见陷阱:重复创建属性分类

问题现象:对同一对象多次设置自定义属性时,会创建多个同名的属性分类,而不是更新现有属性。

错误做法

// ❌ 错误:硬编码索引会导致重复创建
propertyNode.SetUserDefined(1, "分层信息", "Floor_Category", propertyVector);

正确做法

// ✅ 正确:动态查找现有属性的索引
int existingIndex = GetFloorAttributeIndex(propertyNode, "分层信息");
if (existingIndex >= 0)
{
    // 更新现有属性注意索引转换查找用0基设置用1基
    propertyNode.SetUserDefined(existingIndex + 1, "分层信息", "Floor_Category", propertyVector);
}
else
{
    // 创建新属性
    propertyNode.SetUserDefined(0, "分层信息", "Floor_Category", propertyVector);
}

🔍 ClassName vs ClassUserName 的区别

这是导致重复创建问题的根本原因:

ClassUserName:用户定义的显示名称,由我们控制

// 我们定义的显示名称
attribute.ClassUserName == "分层信息"

ClassName:系统生成的内部标识符,我们无法控制

// 系统生成的内部名称,每次可能不同
attribute.ClassName == "LcOaPropOverrideCat"  // 系统生成,不可预测

修复要点

// ❌ 错误:同时匹配两个条件会导致找不到现有属性
if (attribute.UserDefined && 
    attribute.ClassUserName == FLOOR_CATEGORY &&
    attribute.ClassName == FLOOR_CATEGORY_INTERNAL)

// ✅ 正确:只匹配用户显示名称
if (attribute.UserDefined && 
    attribute.ClassUserName == FLOOR_CATEGORY)

📝 索引管理的正确方式

索引计算规则

  1. 遍历所有属性,只计算 UserDefined == true 的属性
  2. 查找方法返回的是0基索引第一个用户属性是0
  3. SetUserDefined方法使用的是1基索引第一个用户属性是1
  4. RemoveUserDefined方法也使用1基索引
// ✅ 正确的索引转换
int foundIndex = GetFloorAttributeIndex(propertyNode, categoryName);  // 返回0, 1, 2...
if (foundIndex >= 0)
{
    // SetUserDefined 需要1基索引
    int setIndex = foundIndex + 1;  // 转换为 1, 2, 3...
    propertyNode.SetUserDefined(setIndex, categoryName, internalName, propertyVector);
    
    // RemoveUserDefined 也需要1基索引
    int removeIndex = foundIndex + 1;  // 转换为 1, 2, 3...
    propertyNode.RemoveUserDefined(removeIndex);
}

🛠️ 完整的修复模板

public class CustomPropertyManager
{
    // ✅ 设置自定义属性的正确方式
    public bool SetCustomProperty(ModelItem item, string categoryName, 
                                 string internalName, string propertyName, 
                                 string displayName, string value)
    {
        return ExecuteWithUIThread(() =>
        {
            var state = ComApiBridge.State;
            var comPath = ComApiBridge.ToInwOaPath(item);
            var propertyNode = (ComApi.InwGUIPropertyNode2)state.GetGUIPropertyNode(comPath, false);

            // 🔍 关键:动态查找现有属性索引
            int existingIndex = FindCustomPropertyIndex(propertyNode, categoryName);
            
            // 创建属性内容
            var propertyCategory = (ComApi.InwOaPropertyVec)state.ObjectFactory(
                ComApi.nwEObjectType.eObjectType_nwOaPropertyVec, null, null);
            
            var property = (ComApi.InwOaProperty)state.ObjectFactory(
                ComApi.nwEObjectType.eObjectType_nwOaProperty, null, null);
            property.name = propertyName;
            property.UserName = displayName;
            property.value = value;
            propertyCategory.Properties().Add(property);

            if (existingIndex >= 0)
            {
                // 🎯 更新现有属性(索引+1
                propertyNode.SetUserDefined(existingIndex + 1, categoryName, internalName, propertyCategory);
                LogManager.Info($"更新现有属性分类 '{categoryName}' (index={existingIndex + 1})");
            }
            else
            {
                // 🆕 创建新属性索引0
                propertyNode.SetUserDefined(0, categoryName, internalName, propertyCategory);
                LogManager.Info($"创建新属性分类 '{categoryName}' (index=0)");
            }
            
            return true;
        });
    }

    // ✅ 查找自定义属性索引的正确方式
    private int FindCustomPropertyIndex(ComApi.InwGUIPropertyNode2 propertyNode, string categoryName)
    {
        int userDefinedIndex = 0;
        
        foreach (ComApi.InwGUIAttribute2 attribute in propertyNode.GUIAttributes())
        {
            if (attribute.UserDefined)
            {
                // ⚠️ 关键只匹配ClassUserName不匹配ClassName
                if (attribute.ClassUserName == categoryName)
                {
                    return userDefinedIndex;
                }
                userDefinedIndex++;
            }
        }
        return -1;
    }

    // ✅ 清除自定义属性的正确方式
    public bool ClearCustomProperty(ModelItem item, string categoryName)
    {
        return ExecuteWithUIThread(() =>
        {
            var state = ComApiBridge.State;
            var comPath = ComApiBridge.ToInwOaPath(item);
            var propertyNode = (ComApi.InwGUIPropertyNode2)state.GetGUIPropertyNode(comPath, false);

            int existingIndex = FindCustomPropertyIndex(propertyNode, categoryName);
            if (existingIndex >= 0)
            {
                // 🗑️ 删除属性(索引+1
                propertyNode.RemoveUserDefined(existingIndex + 1);
                LogManager.Info($"删除属性分类 '{categoryName}' (removed index={existingIndex + 1})");
                return true;
            }
            else
            {
                LogManager.Info($"属性分类 '{categoryName}' 不存在,无需删除");
                return false;
            }
        });
    }
}

📋 问题诊断检查列表

当遇到属性重复创建问题时,检查以下要点:

  • 是否使用了硬编码索引如固定使用索引1
  • 是否同时匹配了ClassUserNameClassName
  • 是否正确进行了索引转换0基→1基
  • 是否在主UI线程中执行COM API调用
  • 日志中是否显示"未找到现有属性分类"但实际存在

🎯 修复验证方法

测试步骤

  1. 选择一个对象,设置自定义属性(如"F1"
  2. 再次选择同一对象,设置不同值(如"F2"
  3. 检查属性面板,应该只有一个属性分类
  4. 重复步骤2设置第三个值如"F3"
  5. 确认仍然只有一个属性分类,值为最新设置的值

日志验证

[FloorAttributeManager] 找到楼层属性分类,索引为: 0
[FloorAttributeManager] ✅ 成功更新现有楼层属性分类 (index=1)

💡 经验总结

  1. COM API的ClassUserName是关键:这是我们控制的显示名称,用于匹配现有属性
  2. ClassName不可靠:系统生成的内部标识符,每次可能不同
  3. 索引转换至关重要查找用0基设置/删除用1基
  4. 动态索引查找必不可少:硬编码索引是重复创建问题的根源
  5. 详细日志帮助调试:记录索引查找和转换过程,便于问题定位

13. 缓存刷新和状态同步 ⚠️ 重要

13.1 缓存刷新的必要性

在某些API操作特别是自定义属性的删除Navisworks内部缓存可能与实际状态不同步导致

  • 搜索结果不准确
  • 属性面板显示异常
  • 后续API调用出现意外行为

13.2 安全的缓存刷新方法

基于实际生产环境的测试和验证,以下是推荐的缓存刷新方法:

// ✅ 最安全的缓存刷新方法空集合的SetHidden操作
public static void SafeCacheRefresh()
{
    try
    {
        var document = NavisApplication.ActiveDocument;
        if (document?.Models != null)
        {
            // 使用空集合的SetHidden操作触发缓存更新
            // 这是一个几乎零开销的"伪操作"
            var emptyCollection = new ModelItemCollection();
            document.Models.SetHidden(emptyCollection, false);
            LogManager.WriteLog("✅ 已执行轻量级缓存刷新");
        }
    }
    catch (Exception ex)
    {
        LogManager.WriteLog($"⚠️ 缓存刷新失败,但不影响主要操作: {ex.Message}");
    }
}

13.3 危险的缓存刷新方法

⚠️ 避免使用以下方法,已确认会导致程序崩溃:

// ❌ 危险:会导致程序崩溃
document.Models.ResetAllTemporaryMaterials();

// ❌ 危险:重载过重,可能影响性能
document.ActiveView.RequestDelayedRedraw(ViewRedrawRequests.All);

// ❌ 危险:可能干扰用户界面状态
document.Models.ResetOverriddenTransparency(allItems);

13.4 何时需要缓存刷新

必须刷新的操作

  • COM API删除自定义属性后
  • 大批量修改模型可见性后
  • 复杂的Transform操作后

可选刷新的操作

  • 添加自定义属性(通常自动同步)
  • 简单的颜色或透明度覆盖
  • 单个对象的操作

13.5 实践模式:操作后刷新

// ✅ 推荐模式:操作 + 刷新 + 验证
public int RemoveLogisticsAttributes(ModelItemCollection items)
{
    int successCount = 0;
    
    try
    {
        // 1. 执行主要操作
        foreach (var item in items)
        {
            // COM API删除操作...
            successCount++;
        }
    }
    catch (Exception ex)
    {
        LogManager.WriteLog($"操作失败: {ex.Message}");
    }
    
    // 2. 如果有成功操作,执行缓存刷新
    if (successCount > 0)
    {
        SafeCacheRefresh(); // 使用安全的刷新方法
    }
    
    return successCount;
}

13.6 缓存问题的诊断

常见症状

  • 删除属性后,搜索仍能找到已删除的属性
  • 属性面板显示的内容与API返回不一致
  • 连续相同操作的结果不同

诊断代码

// ✅ 验证缓存同步状态
public bool VerifyCacheSync(ModelItem item, string categoryName)
{
    // 通过NET API检查
    bool netApiResult = HasPropertyCategory(item, categoryName);
    
    // 通过COM API检查
    bool comApiResult = HasPropertyCategoryViaCom(item, categoryName);
    
    if (netApiResult != comApiResult)
    {
        LogManager.Warning($"缓存不同步检测: NET={netApiResult}, COM={comApiResult}");
        SafeCacheRefresh();
        return false;
    }
    
    return true;
}

13.7 多线程环境下的缓存刷新

// ✅ 线程安全的缓存刷新
public async Task SafeCacheRefreshAsync()
{
    await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
    {
        SafeCacheRefresh(); // 确保在主线程中执行
    });
}

13.8 缓存刷新最佳实践

场景 刷新时机 刷新方法 必要性
删除自定义属性 操作完成后 空集合SetHidden 必须
批量隐藏对象 操作完成后 可选择不刷新 可选
Transform动画 动画结束后 空集合SetHidden 推荐
颜色覆盖 通常不需要 不需要

关键原则

  1. 安全第一:只使用验证过的安全方法
  2. 按需刷新:不是所有操作都需要缓存刷新
  3. 异常处理:缓存刷新失败不应该影响主要功能
  4. 线程安全在主UI线程中执行刷新操作

14. 参考官方示例

强烈建议查看以下官方示例了解更多用法:

  • SearchComparisonPlugIn.cs - 搜索性能对比和属性搜索
  • Examiner.cs - LINQ查询和属性过滤示例
  • AutoUserPropsExample.cs - COM API自定义属性操作
  • BasicDockPanePlugin.cs - 基础插件结构
  • DatabaseDockPane/Models.cs - 数据库操作示例
  • ClashDetective 相关示例 - 高级功能示例

碰撞检测模式说明

Hard硬碰撞

  • 检测模型几何体(多边形、线条和/或点)之间发生碰撞的情况,碰撞距离大于设定的容差值

    HardConservative保守硬碰撞

  • 与Hard模式相同但额外尝试检测那些多边形、线条和点虽然不相交但它们所代表的体积可能重叠的情况

  • 举例:两个相同大小的立方体,方向相同,沿其中一个面的垂直方向偏移很短的距离。在这种情况下,体积明显重 叠,但没有三角形或边缘彼此穿过

  • 所有"碰撞"都属于以下情况之一:

    • (i) 面仅仅接触另一个面的边缘
    • (ii) 共面的面在2D空间中重叠但在3D中没有相交
    • (iii) 边仅仅接触另一个边的端点
    • (iv) 共轴的边在1D中重叠但同样不是以3D方式检测到的
  • HardConservative碰撞检测增加了检测这些复杂情况的逻辑但可能会产生误报

    Clearance间隙检测

  • 检测几何体之间的最小分离距离小于指定容差的情况

  • 例如可用于检测是否有足够空间放置CAD文件中未建模的保温材料

    Duplicate重复检测

  • 检测模型中完全相同的几何体出现多次的情况

  • 当合并包含相同第三方文件的两个文件时可能发生这种情况

  • 超出容差范围的重复几何体被视为不同对象,不会被报告

    这些不同的检测模式为不同的工程应用场景提供了精确的碰撞分析能力。

    容差值说明

    对于Hard硬碰撞和HardConservative保守硬碰撞测试

    • 容差值是在干涉被认定为碰撞之前允许的重叠程度
    • 这允许您忽略数值近似问题和可以在现场解决的轻微碰撞

    对于Clearance间隙检测测试

    • 容差值是所需的最小间隙距离,低于此值的分离将被视为碰撞

    对于Duplicate重复检测测试

    • 容差值是相同几何体被视为重复的最大距离
    • 超出此距离的物体将被忽略,理论上它们虽然相同但是真正独立的实例

    简单来说:

    • Hard/HardConservative容差 = 允许的最大重叠距离
    • Clearance容差 = 要求的最小间隙距离
    • Duplicate容差 = 识别重复的最大距离

    这种设计让用户可以根据实际工程需求调整检测的敏感度。

    设置几何类型:包含面、线和点以获得最全面的碰撞检测

    // 创建临时测试以获取真实碰撞结果
    var tempTestName = $"临时测试_{resultCount}_{DateTime.Now:HHmmss_fff}";
    var tempTest = new ClashTest
    {
        DisplayName = tempTestName,
        TestType = ClashTestType.HardConservative,
        Tolerance = detectionGap,
        Guid = Guid.Empty,
        MergeComposites = true
    };

    collisionTest.SelectionA.PrimitiveTypes = PrimitiveTypes.Triangles | PrimitiveTypes.Lines | PrimitiveTypes.Points;
    collisionTest.SelectionB.PrimitiveTypes = PrimitiveTypes.Triangles | PrimitiveTypes.Lines | PrimitiveTypes.Points;

ModelGeometry 属性

ModelGeometry类公开了以下成员

属性

名称 说明
ActiveColor 此几何体的当前(可见)颜色
ActiveTransform 返回几何体当前活动的变换矩阵
ActiveTransparency 此几何体的当前(可见)透明度
BoundingBox 此几何体在世界坐标系中的包围盒
FragmentCount 此几何体被分割成的片段数量
IsDisposed 获取一个值指示对象是否已被释放且不再可用继承自NativeHandle
IsReadOnly 是否只读重写NativeHandle的IsReadOnly属性
IsSolid 此几何体是否为实体?(包括所有形成封闭、流形外壳的三角形图元)
Item 模型层次结构中对应此几何体的项目
OriginalColor 此几何体的原始颜色(设计文件中指定的)
OriginalTransform 返回几何体加载时的原始变换矩阵
OriginalTransparency 此几何体的原始透明度(设计文件中指定的)
PermanentColor 几何体的永久颜色。可能是原始颜色或用户明确覆盖的颜色
PermanentOverrideTransform 应用于模型几何体原始变换的覆盖变换
PermanentTransform 模型几何体的永久变换。由原始变换与覆盖变换组合形成的变换
PermanentTransparency 几何体的永久透明度。可能是原始透明度或用户明确覆盖的透明度
PrimitiveCount 定义此几何体的图元(三角形、线、点)数量
PrimitiveTypes 用于定义此几何体的图元类型

这些属性提供了访问和查询Navisworks模型几何体各种状态信息的接口包括颜色、变换、透明度、包围盒和几何体结构等重要属性。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Windows.Forms;

using System.Text;

using Autodesk.Navisworks.Api.Controls;

      static public void OutputFirstGeometry()
      {
         try
         {
            if (Autodesk.Navisworks.Api.Application.ActiveDocument != null &&
               !Autodesk.Navisworks.Api.Application.ActiveDocument.IsClear)
            {
               ModelGeometry first =
                  Autodesk.Navisworks.Api.Application.ActiveDocument.
                     Models[0].RootItem.FindFirstGeometry();

               if (first != null)
               {
                  string text = string.Empty;
                  text = string.Format("ActiveColor = {0}" +
                     "\nActiveTransparency = {1}" +
                     "\nBoundingBox  = {2}" +
                     "\nFragmentCount {3}" +
                     "\nIsSolid {4}" +
                     "\nItem {5}" +
                     "\nOriginalColor {6}" +
                     "\nOriginalTransparency {7}" +
                     "\nPermanentColor {8}" +
                     "\nPermanentTransparency {9}",
                     first.ActiveColor.ToString(),
                     first.ActiveTransparency,
                     first.BoundingBox.ToString(),
                     first.FragmentCount,
                     first.IsSolid.ToString(),
                     first.Item.ToString(),
                     first.OriginalColor.ToString(),
                     first.OriginalTransparency,
                     first.PermanentColor.ToString(),
                     first.PermanentTransparency);

                  MessageBox.Show(text);
               }
            }
         }
         catch (Exception e)
         {
            MessageBox.Show(e.Message + "\n\n" + e.ToString());
         }
      }

Tool Enumeration

● Tool 枚举值

成员名称 描述
None 无活动工具
Select 选择工具
SelectBox 框选工具
RedlineFreehand 红线手绘工具
RedlineLine 红线直线工具
RedlineEllipse 红线椭圆工具
RedlineCloud 红线云形标注工具
RedlineLineString 红线连续线工具
RedlineTag 红线标签工具
RedlineText 红线文本工具
RedlineErase 红线擦除工具
RedlineArrow 红线箭头工具
MeasurePointToPoint 点到点测量工具
MeasurePointToMultiplePoints 点到多点测量工具
MeasurePointLine 点线测量工具
MeasureAccumulate 累积测量工具
MeasureAngle 角度测量工具
MeasureArea 面积测量工具
MeasureSingle 单点测量工具
BasicViewObjectWheel 基础视图对象导航轮
BasicTourBuildingWheel 基础建筑巡游导航轮
FullNavigationWheel 完整导航轮
MiniViewObjectWheel 迷你视图对象导航轮
MiniTourBuildingWheel 迷你建筑巡游导航轮
MiniFullNavigationWheel 迷你完整导航轮
Full2DNavigationWheel 完整2D导航轮
CommonPan 通用平移所有Autodesk产品通用
CommonZoom 通用缩放所有Autodesk产品通用
CommonZoomWindow 通用窗口缩放所有Autodesk产品通用
CommonOrbit 通用轨道所有Autodesk产品通用
CommonFreeOrbit 通用自由轨道所有Autodesk产品通用
CommonConstrainedOrbit 通用约束轨道所有Autodesk产品通用
CommonLookAt 通用看向所有Autodesk产品通用
CommonLookAround 通用环视所有Autodesk产品通用
CommonWalk 通用漫游所有Autodesk产品通用
CommonCenter 通用居中所有Autodesk产品通用
NavigateFixed 固定相机位置
NavigateFreeLookAround 经典Navisworks自由环视旋转
NavigateFreeOrbit 经典Navisworks自由轨道检查
NavigateWalk 经典Navisworks漫游
NavigateFly 经典Navisworks飞行
NavigateConstrainedOrbit 经典Navisworks约束轨道转盘
NavigateZoom 经典Navisworks缩放
NavigatePan 经典Navisworks平移
NavigateConstrainedPan 经典Navisworks约束平移
NavigateLookAround 经典Navisworks环视旋转
NavigateOrbit 经典Navisworks轨道
NavigateZoomWindow 经典Navisworks窗口缩放缩放框
CustomToolPlugin 由ToolPlugin提供的自定义功能

这个枚举列出了Navisworks中所有可用的工具类型包括导航工具、测量工具、标注工具等。其中CustomToolPlugin是用于自定 义工具插件的特殊值。

Cursor枚举成员

成员名称 描述
Unhandled 特殊情况。如果已经处理则由GetCursor()返回
Handled 已处理光标
Walk 行走光标
Fly 飞行光标
Orbit 环绕光标
Swivel 旋转光标
Examine 检查光标
Pan 平移光标
Zoom 缩放光标
Turntable 转盘光标
Focus 聚焦光标
Application 应用程序光标
ZoomBox 缩放框光标
Measure 测量光标(十字线)
HyperHand 超级手势光标
PanWorld 世界平移光标
Roll 滚动光标
Stop 停止光标
MeasureEdge 测量边缘光标
MeasureVertex 测量顶点光标
Redline 红线光标
Erase 擦除光标
Wheel 滚轮光标
MarkupSelection 标记选择光标
MarkupSnapping 标记捕捉光标
MarkupEraser 标记擦除光标
MarkupQuickPick 标记快速选择光标
MarkupBucket 标记桶光标
MarkupAutoPoly 标记自动多边形光标

使用方式:

public class PathClickToolPlugin : ToolPlugin
{
    ....

    /// <summary>
    /// 重写光标样式,使用捕捉光标提供更好的视觉反馈
    /// </summary>
    /// <param name="view">当前视图</param>
    /// <param name="modifier">键盘修饰键</param>
    /// <returns>返回捕捉光标</returns>
    public override Cursor GetCursor(View view, KeyModifiers modifier)
    {
        // 使用捕捉光标,提供更好的视觉反馈用于精确路径点选择
        return Cursor.MarkupSnapping;
    }
}

只显示选中项

// 这个示例是"隐藏未选中项"的逻辑

  • Navisworks 的层级隐藏机制:
    • 父节点可见 → 子节点自动可见
    • 父节点隐藏 → 子节点自动隐藏

// 关键点:

  1. visible集合添加了 AncestorsAndSelf祖先路径
  2. visible集合添加了 Descendants所有后代
  3. 但这是为了"保护"这些节点不被隐藏,而不是为了隐藏它们
Examples
CopyHide the unselected ModelItems
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Windows.Forms;

using System.Text;

using Autodesk.Navisworks.Api.Controls;

      static public void HideUnselected()
      {
         //Create hidden collection
         ModelItemCollection hidden = new ModelItemCollection();

         //create a store for the visible items
         ModelItemCollection visible = new ModelItemCollection();

         //Add all the items that are visible to the visible collection
         foreach (ModelItem item in Autodesk.Navisworks.Api.Application.ActiveDocument.CurrentSelection.SelectedItems)
         {
            if (item.AncestorsAndSelf != null)
               visible.AddRange(item.AncestorsAndSelf);
            if (item.Descendants != null)
               visible.AddRange(item.Descendants);
         }

         //mark as invisible all the siblings of the visible items as well as the visible items
         foreach (ModelItem toShow in visible)
         {
            if (toShow.Parent != null)
            {
               hidden.AddRange(toShow.Parent.Children);
            }
         }

         //remove the visible items from the collection
         foreach (ModelItem toShow in visible)
         {
            hidden.Remove(toShow);
         }

         //hide the remaining items
         Autodesk.Navisworks.Api.Application.ActiveDocument.Models.
            SetHidden(hidden, true);
      }

使用进度条

When instantiated a progress bar in the Navisworks style is displayed to the user inside the Navisworks main application. This can then be updated by the program or plugin with textual information as well as the percentage completed of the process.

基础示例

/// <summary>
/// Shows the basic concept of using the Progress class
/// </summary>
private static void SimpleProgressExample()
{
   //first get reference to an instance of the Progress class
   Progress progress = Autodesk.Navisworks.Api.Application.BeginProgress();

   double stage;

   for (stage = 0.0; stage < 1.0; stage += 0.1)
   {
      //Update progress bar
      progress.Update(stage);

      //Do something for a period of time
      System.Threading.Thread.Sleep(1000);
   }

   //Update progress bar to 100%
   progress.Update(1.0);

   //notify the API that the operation has finished
   Autodesk.Navisworks.Api.Application.EndProgress();
}

14.1 Progress API 核心方法详解

Navisworks Progress API 提供了原生的进度条显示功能,可以在耗时操作中向用户展示进度和允许取消操作。

核心方法

// 1. BeginProgress() - 开始进度跟踪(无参数版本)
Progress progress = Application.BeginProgress();

// 2. BeginProgress(string title) - 带标题版本(推荐)
Progress progress = Application.BeginProgress("提取几何体");

// 3. progress.Update(double) - 更新进度0.0 到 1.0
progress.Update(0.5); // 50% 进度

// 4. progress.IsCanceled - 检查用户是否取消
if (progress.IsCanceled)
{
    Application.EndProgress();
    return null;
}

// 5. Application.EndProgress() - 结束进度跟踪
Application.EndProgress();

Progress 类的重要成员

方法/属性 说明
Update(double progress) 更新进度0.0-1.0),显示百分比
IsCanceled 只读属性,用户是否点击取消按钮
BeginSubOperation(double, string) 开始子操作,分配总进度的一部分
EndSubOperation() 结束当前子操作
Hide() 隐藏进度对话框(如需打开其他窗口)
Show() 显示之前隐藏的进度对话框
ReportError(int, string) 向用户报告错误

14.2 带标题的进度条(推荐用法)

/// <summary>
/// 带标题的进度条示例 - 提供更好的用户体验
/// </summary>
public void ProcessWithTitle()
{
    Progress progress = null;
    try
    {
        // 创建带标题的进度条
        progress = Application.BeginProgress("处理模型数据",
            "正在分析 3516 个几何片段...");

        int totalItems = 100;
        for (int i = 0; i < totalItems; i++)
        {
            // 执行处理逻辑
            ProcessItem(i);

            // 更新进度
            progress.Update((double)i / totalItems);
        }

        // 完成
        progress.Update(1.0);
    }
    finally
    {
        // 确保进度条被关闭
        if (progress != null)
        {
            Application.EndProgress();
        }
    }
}

14.3 支持用户取消操作

/// <summary>
/// 支持取消的进度条示例
/// </summary>
public List<Triangle3D> ExtractWithCancellation(IEnumerable<ModelItem> items)
{
    var results = new List<Triangle3D>();
    Progress progress = null;

    try
    {
        progress = Application.BeginProgress("提取三角形",
            "正在提取几何数据,可随时取消...");

        var itemList = items.ToList();
        int totalCount = itemList.Count;

        for (int i = 0; i < totalCount; i++)
        {
            // ✅ 关键:检查用户是否取消
            if (progress.IsCanceled)
            {
                LogManager.Info($"[提取] 用户取消操作,已处理 {i}/{totalCount}");
                break; // 退出循环
            }

            // 执行处理
            var triangles = ExtractTrianglesFromItem(itemList[i]);
            results.AddRange(triangles);

            // 更新进度
            progress.Update((double)(i + 1) / totalCount);
        }

        LogManager.Info($"[提取] 完成,共提取 {results.Count} 个三角形");
    }
    catch (Exception ex)
    {
        LogManager.Error($"[提取] 发生错误: {ex.Message}");
        throw;
    }
    finally
    {
        // ✅ 关键:无论成功、失败还是取消,都要关闭进度条
        if (progress != null)
        {
            Application.EndProgress();
        }
    }

    return results;
}

14.4 子操作SubOperation用法

当任务有多个阶段时,可以使用子操作来细化进度显示:

/// <summary>
/// 使用子操作的多阶段进度示例
/// </summary>
public void MultiStageProcess()
{
    Progress progress = null;

    try
    {
        progress = Application.BeginProgress("处理模型");

        // 阶段1分析模型结构占总进度的20%
        progress.BeginSubOperation(0.2, "正在分析模型结构...");
        var allFragments = AnalyzeModelStructure();
        progress.Update(1.0); // 子操作完成100%
        progress.EndSubOperation();

        // 阶段2提取几何数据占总进度的70%
        progress.BeginSubOperation(0.7, "正在提取几何数据...");
        for (int i = 0; i < allFragments.Count; i++)
        {
            if (progress.IsCanceled) break;

            ProcessFragment(allFragments[i]);
            progress.Update((double)i / allFragments.Count);
        }
        progress.EndSubOperation();

        // 阶段3保存结果占总进度的10%
        progress.BeginSubOperation(0.1, "正在保存结果...");
        SaveResults();
        progress.Update(1.0);
        progress.EndSubOperation();

        LogManager.Info("所有阶段完成");
    }
    finally
    {
        if (progress != null)
        {
            Application.EndProgress();
        }
    }
}

14.5 实际应用:几何体提取中使用进度条

基于实际的 ExtractTriangles 方法,展示如何集成 Progress API

/// <summary>
/// 批量提取三角形(带进度条和取消支持)
/// </summary>
/// <param name="modelItems">模型项集合</param>
/// <returns>三角形集合</returns>
public static List<Triangle3D> ExtractTriangles(IEnumerable<ModelItem> modelItems)
{
    var triangles = new List<Triangle3D>();
    ComApi.InwOpSelection comSelection = null;
    Progress progress = null;

    try
    {
        // 第1步批量转换为 COM 选择
        var modelCollection = new ModelItemCollection();
        int itemCount = 0;
        foreach (var item in modelItems)
        {
            modelCollection.Add(item);
            itemCount++;
        }

        LogManager.Info($"[批量提取] 开始批量提取 {itemCount} 个模型项的三角形");

        var comState = ComStateManager.GetState();
        comSelection = ComApiBridge.ToInwOpSelection(modelCollection);

        // 第2步获取所有片段
        var allFragments = GetAllFragments(comSelection);
        LogManager.Info($"[批量提取] 获取到 {allFragments.Count} 个片段");

        // 🎯 第3步开始进度条
        progress = Application.BeginProgress("提取几何体",
            $"正在提取 {allFragments.Count} 个片段的三角形数据...");

        try
        {
            int processedFragments = 0;
            foreach (var fragmentInfo in allFragments)
            {
                // 🎯 检查用户是否取消
                if (progress.IsCanceled)
                {
                    LogManager.Info($"[批量提取] 用户取消操作,已处理 {processedFragments}/{allFragments.Count}");
                    break;
                }

                try
                {
                    var callback = new OptimizedGeometryCallback(fragmentInfo.TransformMatrix);
                    fragmentInfo.Fragment.GenerateSimplePrimitives(
                        ComApi.nwEVertexProperty.eNORMAL,
                        callback);

                    var fragmentTriangles = callback.GetTriangles();
                    triangles.AddRange(fragmentTriangles);
                    processedFragments++;

                    // 🎯 更新进度(每处理完一个片段更新一次)
                    progress.Update((double)processedFragments / allFragments.Count);
                }
                catch (Exception ex)
                {
                    LogManager.Error($"[批量提取] 处理片段失败: {ex.Message}");
                }
            }

            LogManager.Info($"[批量提取] 片段处理完成,共提取 {triangles.Count} 个三角形");
        }
        finally
        {
            // 释放所有片段COM对象
            foreach (var fragmentInfo in allFragments)
            {
                try
                {
                    if (fragmentInfo.Fragment != null)
                    {
                        Marshal.ReleaseComObject(fragmentInfo.Fragment);
                    }
                }
                catch (Exception ex)
                {
                    LogManager.Warning($"[批量提取] 释放片段COM对象失败: {ex.Message}");
                }
            }
        }
    }
    catch (Exception ex)
    {
        LogManager.Error($"[批量提取] 提取三角形失败: {ex.Message}");
    }
    finally
    {
        // 🎯 确保进度条被关闭
        if (progress != null)
        {
            Application.EndProgress();
        }

        // 释放COM对象避免内存泄漏
        if (comSelection != null)
        {
            Marshal.ReleaseComObject(comSelection);
        }
    }

    return triangles;
}

14.6 Progress API 最佳实践

1. 合理的更新频率

// ✅ 推荐每个片段更新一次3516次调用可接受
progress.Update((double)processedFragments / allFragments.Count);

// ❌ 避免:每个三角形更新(可能数十万次,太频繁)
// progress.Update((double)triangleCount / totalTriangles); // 性能问题

// ✅ 推荐:如果更新太频繁,可以批量更新
if (processedFragments % 10 == 0) // 每10个更新一次
{
    progress.Update((double)processedFragments / allFragments.Count);
}

2. 异常处理中确保关闭

// ✅ 正确模式:使用 finally 确保关闭
Progress progress = null;
try
{
    progress = Application.BeginProgress("处理中...");
    // ... 处理逻辑
}
catch (Exception ex)
{
    LogManager.Error($"处理失败: {ex.Message}");
    // 即使发生异常,进度条也会在 finally 中关闭
}
finally
{
    if (progress != null)
    {
        Application.EndProgress();
    }
}

3. 取消操作的资源清理

// ✅ 正确:取消时也要清理资源
foreach (var item in items)
{
    if (progress.IsCanceled)
    {
        LogManager.Info("用户取消操作");
        CleanupPartialResults(); // 清理部分结果
        break;
    }
    // ... 处理
}

4. 线程安全考虑

// ✅ Progress API 调用必须在主UI线程中
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
    Progress progress = null;
    try
    {
        progress = Application.BeginProgress("处理中...");
        // 所有 Navisworks API 调用都在主线程中
        ProcessInMainThread();
    }
    finally
    {
        if (progress != null)
        {
            Application.EndProgress();
        }
    }
});

14.7 进度条 vs 日志输出对比

特性 Progress API 日志输出
用户体验 实时可视化进度,直观 需要打开日志文件查看
取消操作 支持用户取消 无法中断
性能开销 极小(内存操作) 磁盘I/O开销
调试信息 不保留历史 可追溯问题
适用场景 耗时操作的用户交互 问题诊断和审计

推荐方案:同时使用两者

// 进度条:给用户看,提供交互
progress.Update((double)i / total);

// 日志:只记录关键节点(开始、结束、错误)
LogManager.Info($"[批量提取] 开始提取 {total} 个片段");
// ... 处理过程中不频繁写日志
LogManager.Info($"[批量提取] 完成,共提取 {count} 个三角形");

14.8 完整使用模式总结

推荐的标准模板

public ReturnType MethodWithProgress(InputType input)
{
    Progress progress = null;
    try
    {
        // 1. 开始进度条(带描述性标题)
        progress = Application.BeginProgress("操作标题", "详细描述...");

        // 2. 计算总工作量
        int totalWork = CalculateTotalWork(input);
        int completedWork = 0;

        // 3. 执行主要工作循环
        foreach (var item in workItems)
        {
            // 3.1 检查取消
            if (progress.IsCanceled)
            {
                LogManager.Info("用户取消操作");
                CleanupResources();
                break;
            }

            // 3.2 执行工作
            try
            {
                ProcessItem(item);
                completedWork++;
            }
            catch (Exception ex)
            {
                LogManager.Error($"处理项目失败: {ex.Message}");
                // 决定是继续还是中断
            }

            // 3.3 更新进度
            progress.Update((double)completedWork / totalWork);
        }

        // 4. 完成
        LogManager.Info($"操作完成,处理了 {completedWork}/{totalWork} 项");
        return result;
    }
    catch (Exception ex)
    {
        LogManager.Error($"操作失败: {ex.Message}");
        throw;
    }
    finally
    {
        // 5. 确保清理
        if (progress != null)
        {
            Application.EndProgress();
        }
        CleanupResources();
    }
}

常见陷阱和避免方法

// ❌ 陷阱1忘记关闭进度条
Progress progress = Application.BeginProgress("处理中...");
// ... 处理逻辑
// 忘记调用 Application.EndProgress(); // 进度条会一直显示

// ✅ 正确:使用 try-finally
Progress progress = null;
try
{
    progress = Application.BeginProgress("处理中...");
    // ... 处理逻辑
}
finally
{
    if (progress != null) Application.EndProgress();
}

// ❌ 陷阱2进度值超出范围
progress.Update(1.5); // 错误!应该是 0.0-1.0

// ✅ 正确:确保范围
double progressValue = Math.Min(1.0, (double)completed / total);
progress.Update(progressValue);

// ❌ 陷阱3在后台线程中调用
await Task.Run(() =>
{
    var progress = Application.BeginProgress("处理中..."); // 崩溃!
});

// ✅ 正确:在主线程中调用
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
    var progress = Application.BeginProgress("处理中...");
    // ... 主线程中的处理
});

性能优化建议

场景 更新策略 原因
少量项目(<100 每项更新一次 用户体验好,开销可忽略
中等项目100-1000 每项更新一次 平衡体验和性能
大量项目1000-10000 每10项更新一次 减少UI刷新开销
海量项目(>10000 每100项更新一次 显著减少UI开销

官方示例参考

官方示例文件中的 Progress API 使用案例:

  • ClashDetective/ClashGrouper/ClashGrouperUtils.cs - 碰撞分组中的进度条使用
  • 展示了在复杂算法中如何正确使用 Progress API 和取消支持

Navisworks的Models.CollectionChanged事件模式

事件模式总结

场景1第一次打开文件2ND_FLOOR

  • 事件#1 (6.6秒)Count=0状态保持无模型文档清空准备加载

  • 事件#2 (6.7秒)Count=1从无模型→有模型文件加载完成

    场景2重新打开新文件4TH_FLOOR

  • 事件#3 (25.6秒)Count=0从有模型→无模型旧文件被清空

  • 事件#4 (25.6秒)Count=1从无模型→有模型新文件加载完成

    场景3关闭程序

  • 事件#5 (39.8秒)Count=0从有模型→无模型程序关闭前清理

    关键发现

    1. 每次文档操作都有两个事件:
    • 先清空ModelsCount=0
    • 再加载新ModelsCount>0
    1. 状态转换清晰:
    • 无模型→无模型:初始清理
    • 无模型→有模型:文档加载完成 这是我们需要初始化的时机
    • 有模型→无模型:文档关闭/切换 这是我们需要清理的时机
    1. 时间间隔很短事件成对出现间隔仅0.1秒左右

Navisworks Fragment和Path的API分析

从COM API文档可以看到几个关键信息

  1. Fragment概念第8页

A Fragment is the representation of (possibly part of) a particular instance of a Geometry node within the scene graph. Large Geometry may be broken into multiple Fragments for more efficient displaying.

关键点:

  • Fragment是几何体的一个片段或部分

  • 大型几何体会被分割为多个Fragment以提高显示效率

  • 每个Fragment提供访问

    • 完整的Transform3f从本地到世界坐标空间
    • Fragment代表的Path
    • 用于显示的几何体
    • 用于显示的外观
    1. Path概念第8页

    A Path is the sequence of nodes from the Partition at the root of the scene graph to a particular node within it that uniquely specifies a particular instance.

    关键点:

  • Path是从根分区到特定节点的节点序列

  • Path用来唯一标识特定实例

  • 但这里的"实例"指的是场景图中的节点实例,不是几何体的不同部分

    1. 你的地板问题分析

    真正的问题:

    COM API明确说明了"大型几何体会被分割为多个Fragment"。你的地板有226个Fragment这些Fragment都有相同的路径键值 [1,1,1,1,1,1,1],这是完全正常的:

  • 相同Path因为这226个Fragment都属于同一个ModelItem地板

  • 不同Fragment因为地板几何体很复杂被Navisworks自动分割为226个显示片段

    去重逻辑的错误:

    当前的去重逻辑错误地认为: // 错误假设:相同路径 = 重复实例 if (pathKey相同) { 去重(); // 错误丢失了地板的225个片段 }

    正确的理解:

  • 多实例去重应该针对不同ModelItem的相同几何体

  • 多片段保留应该保留同一ModelItem的所有Fragment

    1. 解决方案建议

    需要修改去重逻辑,区分:

    1. 真正的多实例:不同位置的相同几何体(如多个相同的门)
    2. 几何体分片:同一几何体的多个显示片段(如复杂地板)

    可能的改进方向: // 改进的去重逻辑 var uniqueKey = new { PathKey = fragment.path.ArrayData, GeometryHash = fragment.GetGeometryHash(), // 需要调研是否有此API Transform = fragment.GetLocalToWorldMatrix() // 考虑变换信息 };

    这样可以正确处理:

  • 相同门窗的多实例 → 正确去重

  • 地板的多片段 → 正确保留

Setting “Units and Transform” values via Navisworks API

https://blog.autodesk.io/setting-units-and-transform-values-via-navisworks-api/

DocumentModels models = doc.Models; //Get the required model from DocumentModels Model model; Transform3D oldTransform3d=model.Transform; Transform3DComponents transform3dComponents = oldTransform3d.Factor(); //Get Values Vector3D originVector3D = transform3dComponents.Translation; Vector3D scaleVector3D = transform3dComponents.Scale; Rotation3D rotationVector3D = transform3dComponents.Rotation; //Set Values transform3dComponents.Translation = new Vector3D(origin_X, origin_Y, origin_Z); //Eg: new Vector3D(10,10,10); transform3dComponents.Rotation = new Rotation3D(new UnitVector3D(0, 0, 1), 0.872665); //Here 50 degree=0.872665 radian transform3dComponents.Scale = new Vector3D(scale_X, scale_Y, scale_Z); //Eg: new Vector3D(2,2,2); Transform3D newTransform3D = transform3dComponents.Combine(); //Change model units value Units units = Units.Meters; models.SetModelUnitsAndTransform(model, units , newTransform3D, true);

如何在运行时唯一标识一个ModelItem

使用 ModelItem.GetHashCode() 作为唯一标识符 使用 HashSet 来存储和比较碰撞对象,这样可以正确使用 ModelItem.Equals() 和 GetHashCode() 方法,避免哈希冲突和跨运行时不一致的问题

13. 使用 DocumentModels API 持久化 ModelItem 引用

13.1 核心概念

问题场景:需要在不同会话之间保存和恢复 ModelItem 的引用(如碰撞检测结果、动画对象等)

核心洞察:使用 DocumentModels.CreatePathId()ResolvePathId() 方法实现跨会话的 ModelItem 持久化

13.2 ModelItemPathId 结构

// ✅ ModelItemPathId 包含两个关键信息
public class ModelItemPathId
{
    public int ModelIndex { get; set; }  // 模型索引NWD文件中的模型编号
    public string PathId { get; set; }   // 路径标识符(如 "0/682/0"
}

// 🔍 PathId 格式说明:
// - 使用斜杠分隔的层级路径
// - 例如:"0/682/0" 表示从根节点到目标节点的路径
// - 每个数字代表该层级的索引位置

13.3 保存 ModelItem 到数据库

// ✅ 正确方法:使用 CreatePathId 获取持久化标识
public void SaveCollisionObject(ModelItem collidedObject, int resultId)
{
    var document = Application.ActiveDocument;
    
    // 获取 ModelItem 的持久化标识
    var pathId = document.Models.CreatePathId(collidedObject);
    
    // 保存到数据库
    var record = new ClashDetectiveCollisionObjectRecord
    {
        ResultId = resultId,
        ModelIndex = pathId.ModelIndex,      // 保存模型索引
        PathId = pathId.PathId,               // 保存路径标识
        DisplayName = ModelItemAnalysisHelper.GetSafeDisplayName(collidedObject),
        ObjectName = ModelItemAnalysisHelper.GetSafeDisplayName(collidedObject)
    };
    
    pathDatabase.SaveClashDetectiveCollisionObject(record);
}

13.4 从数据库恢复 ModelItem

// ✅ 正确方法:使用 ResolvePathId 恢复 ModelItem
public ModelItem LoadCollisionObject(int? modelIndex, string pathId)
{
    try
    {
        var document = Application.ActiveDocument;
        
        // 验证必需参数
        if (!modelIndex.HasValue)
        {
            throw new InvalidOperationException("ModelIndex 为空,无法恢复 ModelItem");
        }
        
        if (string.IsNullOrEmpty(pathId))
        {
            throw new InvalidOperationException("PathId 为空,无法恢复 ModelItem");
        }
        
        // 构造 ModelItemPathId 对象
        var pathIdObj = new Autodesk.Navisworks.Api.DocumentParts.ModelItemPathId();
        pathIdObj.ModelIndex = modelIndex.Value;
        pathIdObj.PathId = pathId;
        
        // 解析路径并恢复 ModelItem
        ModelItem restoredItem = document.Models.ResolvePathId(pathIdObj);
        
        if (restoredItem == null)
        {
            throw new InvalidOperationException($"无法通过 PathId 找到 ModelItem: ModelIndex={modelIndex}, PathId={pathId}");
        }
        
        // 验证恢复的对象是否有效
        if (!ModelItemAnalysisHelper.IsModelItemValid(restoredItem))
        {
            throw new InvalidOperationException($"恢复的 ModelItem 无效: ModelIndex={modelIndex}, PathId={pathId}");
        }
        
        return restoredItem;
    }
    catch (Exception ex)
    {
        LogManager.Error($"恢复 ModelItem 失败: ModelIndex={modelIndex}, PathId={pathId}", ex);
        throw;
    }
}

13.5 完整示例:碰撞检测结果的保存和加载

// ✅ 保存碰撞检测结果
public void SaveClashDetectiveResults(List<CollisionResult> collisions, ModelItem vehicle)
{
    var document = Application.ActiveDocument;
    
    // 1. 保存车辆对象信息
    var vehiclePathId = document.Models.CreatePathId(vehicle);
    var testRecord = new ClashDetectiveResultRecord
    {
        TestName = GenerateTestName(),
        IsVirtualVehicle = false,
        VehicleModelIndex = vehiclePathId.ModelIndex,
        VehiclePathId = vehiclePathId.PathId,
        // ... 其他字段
    };
    
    int resultId = pathDatabase.SaveClashDetectiveResult(testRecord);
    
    // 2. 保存所有被撞对象
    var collisionObjects = new List<ClashDetectiveCollisionObjectRecord>();
    
    foreach (var collision in collisions)
    {
        if (collision.Item2 != null)  // 只保存被撞对象Item2
        {
            var objectPathId = document.Models.CreatePathId(collision.Item2);
            
            collisionObjects.Add(new ClashDetectiveCollisionObjectRecord
            {
                ResultId = resultId,
                ModelIndex = objectPathId.ModelIndex,
                PathId = objectPathId.PathId,
                DisplayName = ModelItemAnalysisHelper.GetSafeDisplayName(collision.Item2),
                ObjectName = ModelItemAnalysisHelper.GetSafeDisplayName(collision.Item2)
            });
        }
    }
    
    pathDatabase.SaveClashDetectiveCollisionObjects(resultId, collisionObjects);
}

// ✅ 加载碰撞检测结果
public List<CollisionResult> LoadClashDetectiveResults(string testName)
{
    var document = Application.ActiveDocument;
    
    // 1. 从数据库读取测试信息
    var testInfo = pathDatabase.GetClashDetectiveResult(testName);
    if (testInfo == null)
    {
        throw new InvalidOperationException($"未找到测试记录: {testName}");
    }
    
    // 2. 恢复车辆对象
    ModelItem vehicle;
    if (testInfo.IsVirtualVehicle)
    {
        // 虚拟车辆:创建新的虚拟车辆对象
        vehicle = VirtualVehicleManager.Instance.CreateVirtualVehicle(
            testInfo.VirtualVehicleLength,
            testInfo.VirtualVehicleWidth,
            testInfo.VirtualVehicleHeight
        );
    }
    else
    {
        // 真实车辆:使用 ResolvePathId 恢复
        vehicle = LoadCollisionObject(testInfo.VehicleModelIndex, testInfo.VehiclePathId);
    }
    
    // 3. 恢复所有被撞对象并重建碰撞结果
    var collisionObjects = pathDatabase.GetClashDetectiveCollisionObjects(testInfo.Id);
    var results = new List<CollisionResult>();
    
    foreach (var obj in collisionObjects)
    {
        ModelItem collidedObject = LoadCollisionObject(obj.ModelIndex, obj.PathId);
        
        var collisionResult = new CollisionResult
        {
            ClashGuid = Guid.NewGuid(),
            DisplayName = $"历史碰撞: {obj.DisplayName}",
            Status = ClashResultStatus.Active,
            Item1 = vehicle,
            Item2 = collidedObject,
            Center = collidedObject.BoundingBox().Center,
            Distance = 0.0,
            CreatedTime = DateTime.Now,
            OriginalItem1 = vehicle,
            OriginalItem2 = collidedObject,
            HasContainerMapping = true
        };
        
        results.Add(collisionResult);
    }
    
    return results;
}

13.6 数据库表设计建议

-- ✅ 推荐的数据库表结构(最小化冗余)
CREATE TABLE ClashDetectiveCollisionObjects (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    ResultId INTEGER NOT NULL,
    ModelIndex INTEGER,           -- 模型索引(可为空)
    PathId TEXT,                  -- 路径标识符(可为空)
    DisplayName TEXT,             -- 显示名称用于UI显示
    ObjectName TEXT,              -- 对象名称(用于日志)
    FOREIGN KEY(ResultId) REFERENCES ClashDetectiveResults(Id)
);

-- ❌ 避免冗余字段
-- ModelItemPath TEXT,  -- 与 PathId 重复,不需要

关键要点

  • 最小化存储:只需要 ModelIndexPathId 两个字段即可恢复对象
  • 避免冗余:不要存储与 PathId 相同的 ModelItemPath 字段
  • 保留显示信息DisplayName 和 ObjectName 用于 UI 显示和日志,不影响对象恢复

13.7 常见问题和解决方案

问题1PathId 类型混淆

// ❌ 错误:以为 PathId 是 int 类型
public class ClashDetectiveCollisionObjectRecord
{
    public int? PathId { get; set; }  // 错误PathId 是 string不是 int
}

// ✅ 正确PathId 是 string 类型
public class ClashDetectiveCollisionObjectRecord
{
    public string PathId { get; set; }  // 正确:格式如 "0/682/0"
}

问题2参数验证不充分

// ❌ 错误:没有验证参数
public ModelItem LoadObject(int? modelIndex, string pathId)
{
    var pathIdObj = new ModelItemPathId();
    pathIdObj.ModelIndex = modelIndex.Value;  // 可能为空
    pathIdObj.PathId = pathId;                // 可能为空
    return document.Models.ResolvePathId(pathIdObj);
}

// ✅ 正确:验证参数并抛出明确异常
public ModelItem LoadObject(int? modelIndex, string pathId)
{
    if (!modelIndex.HasValue)
    {
        throw new InvalidOperationException("ModelIndex 为空,无法恢复 ModelItem");
    }
    
    if (string.IsNullOrEmpty(pathId))
    {
        throw new InvalidOperationException("PathId 为空,无法恢复 ModelItem");
    }
    
    var pathIdObj = new ModelItemPathId();
    pathIdObj.ModelIndex = modelIndex.Value;
    pathIdObj.PathId = pathId;
    
    var restoredItem = document.Models.ResolvePathId(pathIdObj);
    
    if (restoredItem == null)
    {
        throw new InvalidOperationException($"无法通过 PathId 找到 ModelItem: ModelIndex={modelIndex}, PathId={pathId}");
    }
    
    return restoredItem;
}

问题3向后兼容代码掩盖问题

// ❌ 错误:使用向后兼容逻辑掩盖问题
public ModelItem LoadObject(int? modelIndex, string pathId, string oldPath)
{
    ModelItem item = null;
    
    // 尝试新方法
    if (modelIndex.HasValue && !string.IsNullOrEmpty(pathId))
    {
        try
        {
            item = document.Models.ResolvePathId(new ModelItemPathId { ModelIndex = modelIndex.Value, PathId = pathId });
        }
        catch { }  // 静默失败
    }
    
    // 回退到旧方法
    if (item == null && !string.IsNullOrEmpty(oldPath))
    {
        item = FindModelItemByOldPath(oldPath);  // 旧方法
    }
    
    return item;  // 可能返回 null掩盖了问题
}

// ✅ 正确:失败直接抛出异常,不掩盖问题
public ModelItem LoadObject(int? modelIndex, string pathId)
{
    if (!modelIndex.HasValue || string.IsNullOrEmpty(pathId))
    {
        throw new InvalidOperationException($"参数无效: ModelIndex={modelIndex}, PathId={pathId}");
    }
    
    try
    {
        var pathIdObj = new ModelItemPathId { ModelIndex = modelIndex.Value, PathId = pathId };
        var item = document.Models.ResolvePathId(pathIdObj);
        
        if (item == null)
        {
            throw new InvalidOperationException($"无法找到 ModelItem: ModelIndex={modelIndex}, PathId={pathId}");
        }
        
        return item;
    }
    catch (Exception ex)
    {
        throw new InvalidOperationException($"恢复 ModelItem 失败: ModelIndex={modelIndex}, PathId={pathId}", ex);
    }
}

13.8 最佳实践总结

最佳实践 说明 示例
使用 CreatePathId 获取 ModelItem 的持久化标识 var pathId = document.Models.CreatePathId(item)
保存 ModelIndex 和 PathId 两个字段都是必需的 ModelIndex = pathId.ModelIndex, PathId = pathId.PathId
验证参数 加载前验证参数有效性 if (!modelIndex.HasValue) throw ...
失败抛出异常 不掩盖问题,直接抛出异常 if (item == null) throw ...
避免冗余字段 不存储与 PathId 重复的数据 删除 ModelItemPath 字段
保留显示信息 DisplayName 用于 UI 显示 DisplayName = item.DisplayName
线程安全 在主线程中执行 API 调用 使用 Dispatcher.Invoke 包装
错误处理 提供详细的错误信息 包含 ModelIndex 和 PathId 在异常消息中

13.9 性能和可靠性考虑

  1. PathId 的稳定性

    • 只要 NWD 文件结构不变PathId 就会保持稳定
    • 如果 NWD 文件被重新导出或结构改变PathId 可能失效
  2. 批量恢复优化

    // ✅ 批量恢复时,先收集所有 PathId
    var pathIds = collisionObjects.Select(obj => new ModelItemPathId 
    { 
        ModelIndex = obj.ModelIndex.Value, 
        PathId = obj.PathId 
    }).ToList();
    
    // 然后一次性恢复(如果 API 支持)
    // 或者逐个恢复并收集失败的对象
    var failedItems = new List<string>();
    foreach (var pathId in pathIds)
    {
        try
        {
            var item = document.Models.ResolvePathId(pathId);
            // 处理恢复的对象
        }
        catch (Exception ex)
        {
            failedItems.Add($"{pathId.ModelIndex}:{pathId.PathId}");
        }
    }
    
  3. 缓存机制

    // ✅ 使用缓存减少重复查询
    private Dictionary<string, ModelItem> _modelItemCache = new Dictionary<string, ModelItem>();
    
    public ModelItem GetOrLoadModelItem(int? modelIndex, string pathId)
    {
        string cacheKey = $"{modelIndex}:{pathId}";
    
        if (_modelItemCache.TryGetValue(cacheKey, out ModelItem cachedItem))
        {
            return cachedItem;
        }
    
        var item = LoadCollisionObject(modelIndex, pathId);
        _modelItemCache[cacheKey] = item;
        return item;
    }
    

13.10 与其他持久化方法的对比

方法 优点 缺点 适用场景
CreatePathId/ResolvePathId 官方API稳定可靠跨会话有效 需要保存两个字段 推荐用于所有持久化场景
ModelItem.GetHashCode() 简单,单个值 跨会话无效,哈希冲突 仅用于运行时去重

14. Clash Detective API 使用方法

14.1 Clash Detective 检测行为的关键洞察 ⚠️ 重要

基于实际测试验证的核心发现2026-01-13

14.1.1 输入 vs 输出的对象类型差异

⚠️ 核心发现Clash Detective 返回的对象与输入的对象类型不同

// 🔍 实际测试验证:
// 输入给 Clash Detective 的对象:
var selectionA = new ModelItemCollection { compositeItem };  // IsComposite=True
var selectionB = new ModelItemCollection { compositeItem2 }; // IsComposite=True

test.SelectionA.Selection.CopyFrom(selectionA);
test.SelectionB.Selection.CopyFrom(selectionB);

// Clash Detective 返回的对象:
ClashResult result = test.Children[0] as ClashResult;
// result.Item1.IsComposite = False  ← 注意:变成了 False
// result.Item2.IsComposite = False  ← 注意:变成了 False
// result.Item1.DisplayName = ""     ← 注意:名字是空的
// result.Item2.DisplayName = ""     ← 注意:名字是空的

日志验证

[ClashDetective输入] 候选1: Item1=Chair Sitting Square (IsComposite=True), Item2=Chair-Stacking (IsComposite=True)
[ClashDetective结果] 原始碰撞: Item1= (IsComposite=False), Item2= (IsComposite=False)
[ClashDetective结果] 容器映射: Item1=Chair Sitting Square (IsComposite=True), Item2=Chair-Stacking (IsComposite=True)

14.1.2 Clash Detective 的内部行为

关键理解

  1. 输入对象:我们给 Clash Detective 的是复合对象(IsComposite=True
  2. 检测过程Clash Detective 深入到几何体级别进行碰撞检测
  3. 返回对象Clash Detective 返回的是实际发生碰撞的几何体子节点(IsComposite=False
  4. 命名问题:几何体子节点通常没有 DisplayName所以返回的名字是空的

为什么这样设计

  • Clash Detective 需要精确到几何体级别进行碰撞检测
  • 复合对象可能包含多个几何体子节点
  • 返回具体的几何体节点可以提供更精确的碰撞位置信息

14.1.3 正确的处理方法

解决方案:使用 FindNamedParentContainer 映射回复合对象

// ✅ 正确方法:容器映射
foreach (var child in collisionGroup.Children)
{
    if (child is ClashResult clashResult)
    {
        // 1. Clash Detective 返回的是几何体级别的子节点
        var originalItem1 = clashResult.Item1;   // IsComposite=False, DisplayName=""
        var originalItem2 = clashResult.Item2;   // IsComposite=False, DisplayName=""
        
        // 2. 向上查找有意义的父级容器
        var compositeItem1 = ModelItemAnalysisHelper.FindNamedParentContainer(clashResult.Item1);
        var compositeItem2 = ModelItemAnalysisHelper.FindNamedParentContainer(clashResult.Item2);
        
        // 3. 使用复合对象创建碰撞结果
        var collisionResult = new CollisionResult
        {
            ClashGuid = clashResult.Guid,
            DisplayName = clashResult.DisplayName,
            Status = clashResult.Status,
            Item1 = compositeItem1,  // IsComposite=True, DisplayName="Chair Sitting Square"
            Item2 = compositeItem2,  // IsComposite=True, DisplayName="Chair-Stacking"
            Center = clashResult.Center,
            Distance = clashResult.Distance,
            CreatedTime = DateTime.Now
        };
    }
}

14.1.4 FindNamedParentContainer 的实现

// ✅ 向上查找有意义的父级容器
public static ModelItem FindNamedParentContainer(ModelItem geometryItem)
{
    if (geometryItem == null)
        return null;
    
    ModelItem current = geometryItem;
    
    // 向上遍历父节点链
    while (current != null)
    {
        // 如果当前节点有名字且是复合对象,返回它
        if (!string.IsNullOrEmpty(current.DisplayName) && current.IsComposite)
        {
            return current;
        }
        
        // 继续向上查找
        current = current.Parent;
    }
    
    // 如果没找到,返回原始对象
    return geometryItem;
}

14.1.5 容器映射的必要性

为什么必须进行容器映射

  1. 用户可读性:复合对象有有意义的名字(如"Chair-Stacking"),几何体子节点没有名字
  2. 高亮显示:高亮复合对象比高亮几何体子节点更直观
  3. 碰撞报告:报告需要显示有意义的对象名称
  4. 数据一致性预计算使用复合对象Clash Detective 结果也应该使用复合对象

不进行容器映射的问题

// ❌ 错误:直接使用 Clash Detective 返回的对象
var collisionResult = new CollisionResult
{
    Item1 = clashResult.Item1,  // DisplayName="", IsComposite=False
    Item2 = clashResult.Item2   // DisplayName="", IsComposite=False
};

// 问题:
// 1. 碰撞报告中显示"未命名对象"
// 2. 高亮显示的是几何体子节点(用户看不到)
// 3. 无法与预计算结果对应(预计算使用复合对象)

14.1.6 性能考虑

容器映射的性能影响

  • 每次碰撞结果都需要向上遍历父节点链
  • 对于大型模型,可能影响性能
  • 建议缓存容器映射结果

优化方案

// ✅ 使用缓存优化容器映射
private Dictionary<ModelItem, ModelItem> _containerCache = new Dictionary<ModelItem, ModelItem>();

public ModelItem FindNamedParentContainerCached(ModelItem geometryItem)
{
    if (geometryItem == null)
        return null;
    
    // 检查缓存
    if (_containerCache.TryGetValue(geometryItem, out ModelItem cachedContainer))
    {
        return cachedContainer;
    }
    
    // 执行容器映射
    var container = FindNamedParentContainer(geometryItem);
    
    // 缓存结果
    _containerCache[geometryItem] = container;
    
    return container;
}

14.1.7 与 MergeComposites 选项的关系

MergeComposites 选项的作用

var test = new ClashTest
{
    DisplayName = testName,
    TestType = ClashTestType.HardConservative,
    Tolerance = detectionGap,
    MergeComposites = true  // 这个选项
};

MergeComposites = true 的效果

  • 将同一个复合对象的多个子节点碰撞合并为一个碰撞
  • 减少重复的碰撞结果
  • 但仍然返回几何体级别的对象(不是复合对象)

验证

// 即使设置了 MergeComposites=true
// Clash Detective 返回的仍然是:
// result.Item1.IsComposite = False
// result.Item2.IsComposite = False
// result.Item1.DisplayName = ""

结论MergeComposites 选项只是减少碰撞数量,不会改变返回对象的类型。容器映射仍然是必需的。

14.1.8 最佳实践总结

场景 方法 注意事项
碰撞检测输入 使用复合对象 提高检测效率
碰撞结果提取 使用容器映射 获取有意义的对象名称
碰撞报告显示 使用复合对象 显示可读的名称
高亮显示 使用复合对象 高亮整个对象
性能优化 缓存容器映射 减少重复计算

关键要点

  1. Clash Detective 输入和输出的对象类型不同
  2. 必须使用 FindNamedParentContainer 进行容器映射
  3. 容器映射确保碰撞结果的名称和可读性
  4. 使用缓存优化容器映射的性能 | 索引路径(旧方法) | 简单易懂 | 不稳定,易失效 | 不推荐使用 | | DisplayName | 易于理解 | 不唯一,可能重复 | 仅用于UI显示 |

结论CreatePathId/ResolvePathId 是 Navisworks API 提供的官方持久化方法,应该作为首选方案。

15. Viewpoint 视角控制 - 实现标准俯视图

基于实际测试验证的关键发现2026-01-27

15.1 核心问题:如何实现"摆正"的俯视图

需求场景:在 Navisworks 中将视角调整为标准的俯视图,视图与世界坐标轴对齐,不倾斜。

常见误区:使用 AlignDirectionAlignUp 或正交投影等方法。

15.2 失败的方法及原因分析

15.2.1 使用 AlignDirection 设置相机方向

// ❌ 失败的方法AlignDirection 不能改变相机方向
Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();
newViewpoint.Position = cameraPosition;
newViewpoint.AlignDirection(new Vector3D(0, 0, -1));  // 指向下
newViewpoint.WorldUpVector = new UnitVector3D(0, 1, 0);

// 实际效果:视图方向没有改变,仍然是倾斜的

失败原因AlignDirection 方法只是调整相机方向,但不能完全重置相机的旋转状态。它依赖于当前的相机姿态进行增量调整,无法实现彻底的"摆正"。

15.2.2 使用 AlignUp 设置向上向量

// ❌ 失败的方法AlignUp 只调整向上向量
Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();
newViewpoint.Position = cameraPosition;
newViewpoint.PointAt(focusCenter);
newViewpoint.AlignUp(new UnitVector3D(0, 0, 1));  // Z轴向上

// 实际效果:视图仍然是俯视,但方向没有摆正

失败原因AlignUp 只是将相机的向上向量绕视图方向旋转,使其与给定的向上向量对齐。但它不改变视图方向本身,只是调整相机的"顶部朝向"。

15.2.3 使用正交投影

// ❌ 错误理解:正交投影 = 摆正视图
newViewpoint.Projection = ViewpointProjection.Orthographic;

// 实际效果:视图仍然是倾斜的,只是投影方式改变了

失败原因正交投影Orthographic Projection是投影方式不是视图方向。它只影响透视效果不会改变相机的姿态和方向。

15.3 成功的方法:直接设置 Rotation

15.3.1 核心发现

关键洞察:要实现标准的俯视图,必须直接设置 Rotation 属性为无旋转的单位四元数。

// ✅ 成功的方法:直接设置 Rotation
Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();

// 1. 设置相机位置在目标正上方
newViewpoint.Position = cameraPosition;

// 2. 🎯 关键:设置 Rotation 为无旋转(单位四元数)
// 这样可以彻底清除任何旋转,使视图与世界坐标轴对齐
newViewpoint.Rotation = new Rotation3D();

// 3. 设置向上向量Y 轴向上(标准俯视图的向上方向)
newViewpoint.WorldUpVector = new UnitVector3D(0, 1, 0);

15.3.2 原理说明

Rotation3D() 是单位四元数

  • 单位四元数表示"无旋转"状态
  • 它代表相机的初始姿态与世界坐标轴完全对齐
  • 相机从原点沿 X 轴正方向看Y 轴向上Z 轴向右

为什么 Rotation3D() 有效

  1. 彻底清除旋转:不依赖任何增量调整,从零开始
  2. 与世界坐标轴对齐:相机姿态完全重置到初始状态
  3. 配合 Position 设置:相机在目标正上方 + 无旋转 = 标准俯视图

视图方向推导

初始姿态(无旋转):
  相机位置:原点 (0, 0, 0)
  视图方向X 轴正方向 (1, 0, 0)
  向上向量Y 轴正方向 (0, 1, 0)

设置 Position = (x, y, z + cameraDistance)
  相机位置:目标正上方
  视图方向:仍然是 X 轴正方向
  向上向量:仍然是 Y 轴正方向

结果:
  相机在目标上方,沿 X 轴方向看
  这不是标准的俯视图

问题:标准的俯视图应该是从 Z 轴向下看,而不是从 X 轴看。

修正方案:需要结合 AlignDirectionPointAt 来设置视图方向。

15.3.3 完整的正确实现

// ✅ 完整的标准俯视图实现
public static void AdjustToStandardTopView(Point3D focusCenter, double cameraDistance)
{
    Document doc = Application.ActiveDocument;
    Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();

    // 1. 设置相机位置在目标正上方
    newViewpoint.Position = new Point3D(
        focusCenter.X,
        focusCenter.Y,
        focusCenter.Z + cameraDistance
    );

    // 2. 🎯 关键步骤1设置 Rotation 为无旋转
    newViewpoint.Rotation = new Rotation3D();

    // 3. 🎯 关键步骤2让相机指向目标Z 轴负方向)
    // 因为 Rotation3D() 后,相机沿 X 轴看,所以需要旋转到向下看
    newViewpoint.AlignDirection(new Vector3D(0, 0, -1));

    // 4. 设置向上向量Y 轴向上
    newViewpoint.WorldUpVector = new UnitVector3D(0, 1, 0);

    // 5. 应用视角
    doc.CurrentViewpoint.CopyFrom(newViewpoint);
}

或者使用 PointAt 方法

// ✅ 使用 PointAt 的替代方案
Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();
newViewpoint.Position = cameraPosition;
newViewpoint.Rotation = new Rotation3D();  // 先清除旋转
newViewpoint.PointAt(focusCenter);         // 然后指向目标
newViewpoint.WorldUpVector = new UnitVector3D(0, 1, 0);

15.4 6 个标准视图方向

根据 3D 坐标系的标准定义6 个视图方向的配置如下:

视图 相机位置 视图方向 向上向量 说明
俯视 (x, y, z+height) (0, 0, -1) (0, 1, 0) 从 Z 轴正方向向下看
仰视 (x, y, z-height) (0, 0, 1) (0, 1, 0) 从 Z 轴负方向向上看
前视 (x, y-height, z) (0, 1, 0) (0, 0, 1) 从 Y 轴负方向向前看
后视 (x, y+height, z) (0, -1, 0) (0, 0, 1) 从 Y 轴正方向向后看
右视 (x+width, y, z) (-1, 0, 0) (0, 0, 1) 从 X 轴正方向向左看
左视 (x-width, y, z) (1, 0, 0) (0, 0, 1) 从 X 轴负方向向右看

实现示例

// ✅ 通用的标准视图设置方法
public static void SetStandardView(Point3D focusCenter, double distance, StandardViewDirection viewDir)
{
    Document doc = Application.ActiveDocument;
    Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();

    // 1. 根据视图方向设置相机位置
    Point3D cameraPos;
    Vector3D viewDirection;
    UnitVector3D upVector;

    switch (viewDir)
    {
        case StandardViewDirection.Top:
            cameraPos = new Point3D(focusCenter.X, focusCenter.Y, focusCenter.Z + distance);
            viewDirection = new Vector3D(0, 0, -1);
            upVector = new UnitVector3D(0, 1, 0);
            break;
        case StandardViewDirection.Front:
            cameraPos = new Point3D(focusCenter.X, focusCenter.Y - distance, focusCenter.Z);
            viewDirection = new Vector3D(0, 1, 0);
            upVector = new UnitVector3D(0, 0, 1);
            break;
        // ... 其他视图方向
        default:
            throw new ArgumentException("不支持的视图方向");
    }

    // 2. 设置相机位置和清除旋转
    newViewpoint.Position = cameraPos;
    newViewpoint.Rotation = new Rotation3D();

    // 3. 设置视图方向和向上向量
    newViewpoint.AlignDirection(viewDirection);
    newViewpoint.WorldUpVector = upVector;

    // 4. 应用视角
    doc.CurrentViewpoint.CopyFrom(newViewpoint);
}

15.5 Viewpoint 属性之间的关系

关键理解Viewpoint 的多个属性共同决定相机的最终姿态。

属性 作用 设置顺序 重要性
Position 相机位置 第一 决定相机在哪里
Rotation 相机旋转 第二 决定相机朝向和倾斜
WorldUpVector 向上向量 第三 决定相机的"顶部"方向
Projection 投影方式 最后 决定透视效果

正确的设置顺序

// ✅ 推荐的设置顺序
1. newViewpoint.Position = cameraPosition;        // 先设置位置
2. newViewpoint.Rotation = new Rotation3D();      // 清除旋转(关键)
3. newViewpoint.AlignDirection(viewDirection);    // 设置视图方向
4. newViewpoint.WorldUpVector = upVector;         // 设置向上向量
5. newViewpoint.Projection = ViewpointProjection.Perspective; // 最后设置投影

15.6 常见 Viewpoint 问题及解决方案

问题 症状 原因 解决方案
视图倾斜 视图不与世界坐标轴对齐 没有清除旋转 先设置 Rotation = new Rotation3D()
方向错误 相机看向错误的方向 AlignDirection 无效 确保先清除旋转再调用 AlignDirection
向上向量错误 视图上下颠倒 WorldUpVector 设置错误 根据视图方向选择正确的向上向量
正交投影误解 视图仍然倾斜 误以为正交投影 = 摆正 正交投影不改变方向,只是投影方式

15.7 Viewpoint 最佳实践

// ✅ 推荐模式:创建标准俯视图
public static Viewpoint CreateStandardTopView(Point3D focusCenter, double distance)
{
    Document doc = Application.ActiveDocument;
    Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();

    // 1. 设置相机位置(在目标正上方)
    newViewpoint.Position = new Point3D(
        focusCenter.X,
        focusCenter.Y,
        focusCenter.Z + distance
    );

    // 2. 🎯 清除旋转(关键步骤)
    newViewpoint.Rotation = new Rotation3D();

    // 3. 设置视图方向(向下看)
    newViewpoint.AlignDirection(new Vector3D(0, 0, -1));

    // 4. 设置向上向量Y 轴向上)
    newViewpoint.WorldUpVector = new UnitVector3D(0, 1, 0);

    return newViewpoint;
}

// ✅ 推荐模式:保存和恢复视角
public static void SaveAndRestoreViewpointExample()
{
    Document doc = Application.ActiveDocument;

    // 保存当前视角
    Viewpoint savedViewpoint = doc.CurrentViewpoint.Value.CreateCopy();

    try
    {
        // 设置俯视图
        Point3D focusCenter = CalculatePathCenter();
        Viewpoint topView = CreateStandardTopView(focusCenter, 10.0);
        doc.CurrentViewpoint.CopyFrom(topView);

        // 执行操作(如截图、碰撞检测等)
        PerformOperationInTopView();
    }
    finally
    {
        // 恢复原始视角
        doc.CurrentViewpoint.CopyFrom(savedViewpoint);
    }
}

关键要点

  1. Rotation3D() 是摆正视图的关键:彻底清除旋转状态
  2. AlignDirection 需要在清除旋转后使用:才能正确设置视图方向
  3. 理解每个属性的作用:不要混淆"视图方向"和"向上向量"
  4. 正确的设置顺序很重要Position → Rotation → AlignDirection → WorldUpVector
  5. 使用 CreateCopy() 而不是 new Viewpoint():保留当前视角的其他属性

13. 视角控制ViewpointAPI

基于 Focus 功能实现的真实API用法总结ViewpointHelper开发经验

13.1 核心API概念

关键发现ModelItem.Transform 不反映 override 后的位置变化,获取实际位置应使用 BoundingBox().Center

视角控制的核心类

类/方法 用途 关键特性
Viewpoint 视角状态对象 可修改 Position、Rotation、WorldUpVector
doc.CurrentViewpoint.Value 获取当前视角 需要用 CreateCopy() 创建副本修改
AlignDirection(Vector3D) 旋转相机指向方向 使用最短路径旋转
AlignUp(Vector3D) 旋转相机对齐向上向量 围绕视线方向旋转
ZoomBox(BoundingBox3D) 强制适应视图 会覆盖相机距离设置
Rotation3D() 清除旋转状态 用于创建标准视角(俯视图等)

13.2 聚焦到模型对象的两种方法

方法一:距离计算法(精确控制物体占屏比例)

适用于聚焦到单个对象,精确控制物体在视图中的占比。

// ✅ 正确:计算相机距离实现目标占比
// 推荐参数viewAngleDegrees = 60.0(视场角), targetViewRatio = 0.251/4占屏
public void FocusOnModelItem(ModelItem item, double viewAngleDegrees, 
    double targetViewRatio)
{
    Document doc = Application.ActiveDocument;
    
    // 1. 获取对象包围盒(模型单位)
    var boundingBox = item.BoundingBox();
    Point3D targetCenter = boundingBox.Center;
    double targetSize = Math.Max(boundingBox.Size.X, boundingBox.Size.Y);
    
    // 2. 计算相机距离(标准透视投影公式)
    // distance = (targetSize / 2) / tan(FOV / 2) / targetViewRatio
    double fovRadians = viewAngleDegrees * Math.PI / 180.0;
    double cameraDistance = (targetSize / 2.0) / Math.Tan(fovRadians / 2.0) / targetViewRatio;
    
    // 3. 计算相机位置使用模型标准45度斜上视角向量
    Vector3D viewDirection = doc.FrontRightTopViewVector;
    Vector3D upVector = doc.FrontRightTopViewUpVector;
    
    Point3D cameraPosition = new Point3D(
        targetCenter.X - viewDirection.X * cameraDistance,
        targetCenter.Y - viewDirection.Y * cameraDistance,
        targetCenter.Z - viewDirection.Z * cameraDistance
    );
    
    // 4. 应用视角
    ApplyViewpoint(cameraPosition, targetCenter, upVector, useAlignDirection: true);
}

// 通用视角应用方法
private static void ApplyViewpoint(Point3D cameraPosition, Point3D targetPoint, 
    Vector3D upVector, bool useAlignDirection)
{
    Document doc = Application.ActiveDocument;
    Viewpoint newViewpoint = doc.CurrentViewpoint.Value.CreateCopy();
    
    newViewpoint.Position = cameraPosition;
    
    if (useAlignDirection)
    {
        // 使用AlignDirection/AlignUp精确控制方向
        Vector3D lookDirection = new Vector3D(
            targetPoint.X - cameraPosition.X,
            targetPoint.Y - cameraPosition.Y,
            targetPoint.Z - cameraPosition.Z);
        lookDirection.Normalize();
        
        newViewpoint.AlignDirection(lookDirection);
        newViewpoint.AlignUp(upVector);
    }
    else
    {
        // 使用Rotation直接设置适用于标准视角
        newViewpoint.Rotation = new Rotation3D();
        newViewpoint.WorldUpVector = new UnitVector3D(upVector.X, upVector.Y, upVector.Z);
    }
    
    doc.CurrentViewpoint.CopyFrom(newViewpoint);
}

关键公式

cameraDistance = (targetSize / 2) / tan(FOV / 2) / targetViewRatio

示例:
- targetSize = 2米, FOV = 45°, ratio = 0.25 (25%占屏)
- cameraDistance = 1.0 / tan(22.5°) / 0.25 ≈ 9.66 米

方法二ZoomBox法确保完整可见

适用于显示路径、多个对象或大型物体,确保完全可见。

// ✅ 正确使用ZoomBox确保完整可见性
public void FocusOnBoundingBox(BoundingBox3D bounds, double marginRatio = 0.1)
{
    Document doc = Application.ActiveDocument;
    
    // 1. 计算扩展后的包围盒(添加边距)
    Vector3D size = bounds.Size;
    Vector3D margin = new Vector3D(
        size.X * marginRatio,
        size.Y * marginRatio,
        size.Z * marginRatio
    );
    
    BoundingBox3D expandedBox = new BoundingBox3D(
        new Point3D(bounds.Min.X - margin.X, bounds.Min.Y - margin.Y, bounds.Min.Z - margin.Z),
        new Point3D(bounds.Max.X + margin.X, bounds.Max.Y + margin.Y, bounds.Max.Z + margin.Z)
    );
    
    // 2. 设置相机位置(估算)
    Point3D targetCenter = expandedBox.Center;
    Vector3D viewDirection = doc.FrontRightTopViewVector;
    double estimatedDistance = Math.Max(size.X, size.Y) * 2.0;
    
    Point3D cameraPosition = new Point3D(
        targetCenter.X - viewDirection.X * estimatedDistance,
        targetCenter.Y - viewDirection.Y * estimatedDistance,
        targetCenter.Z - viewDirection.Z * estimatedDistance
    );
    
    // 3. 应用视角设置Rotation和UpVector
    ApplyViewpoint(cameraPosition, targetCenter, doc.FrontRightTopViewUpVector, 
        useAlignDirection: false);
    
    // 4. 关键使用ZoomBox强制适应视图
    doc.ActiveView.ZoomBox(expandedBox);  // ZoomBox会覆盖相机距离
}

13.3 模型标准视角向量

重要发现Navisworks 提供模型特定的标准视角向量。

// ✅ 获取模型的标准视角方向45度俯视
Vector3D standardViewDirection = doc.FrontRightTopViewVector;
Vector3D standardUpVector = doc.FrontRightTopViewUpVector;

// 其他可用视角向量:
// - FrontViewVector / FrontViewUpVector        // 正视图
// - TopViewVector / TopViewUpVector            // 俯视图
// - RightViewVector / RightViewUpVector        // 右视图
// - FrontRightTopViewVector / ...              // 45度俯视最常用

使用场景

  • Focus功能:使用 FrontRightTopViewVector 保持与UI一致的标准视角
  • 正视图截图:使用 FrontViewVector 获取正交投影效果
  • 俯视图分析:使用 TopViewVector 进行平面分析

13.4 相机距离计算方法对比

方法 适用场景 优点 缺点
FOV公式法 单个对象聚焦 精确控制占屏比例 需要计算目标尺寸
ZoomBox法 路径/多对象显示 确保完全可见,自动计算距离 覆盖相机位置设置
固定距离 快速预览 简单快速 对象大小不一时效果差

FOV计算代码详解

// 计算相机距离的完整公式
private static double CalculateCameraDistance(double targetSize, double fovDegrees, 
    double targetRatio)
{
    // 1. 转换FOV为弧度
    double fovRadians = fovDegrees * Math.PI / 180.0;
    
    // 2. 计算半角正切值
    double halfFovTan = Math.Tan(fovRadians / 2.0);
    
    // 3. 计算目标在FOV中占据一半时的距离
    //    targetSize/2 是目标半宽halfFovTan 是半角正切
    double distanceForFullTarget = (targetSize / 2.0) / halfFovTan;
    
    // 4. 根据目标占屏比例调整距离
    //    ratio=0.5表示目标占视图50%,距离减半
    double finalDistance = distanceForFullTarget / targetRatio;
    
    return finalDistance;
}

// 使用示例:
// targetSize=3m, FOV=45°, ratio=0.25 → distance ≈ 14.5m
// 这意味着相机距离目标14.5米时3米宽的物体将占据视图的25%

13.5 常见错误和解决方案

错误1混用距离计算和ZoomBox

// ❌ 错误先设置相机距离再用ZoomBox覆盖
var viewpoint = doc.CurrentViewpoint.Value.CreateCopy();
viewpoint.Position = calculatedPosition;  // 设置位置
doc.CurrentViewpoint.CopyFrom(viewpoint);
doc.ActiveView.ZoomBox(bounds);  // ZoomBox会覆盖之前的距离设置

// ✅ 正确:二选一
// 方案A只用距离计算不调用ZoomBox
viewpoint.Position = calculatedPosition;
doc.CurrentViewpoint.CopyFrom(viewpoint);

// 方案B用ZoomBox距离会被自动计算
doc.ActiveView.ZoomBox(bounds);

错误2忽略Rotation3D的影响

// ❌ 错误:不清除旋转直接设置视角
var viewpoint = doc.CurrentViewpoint.Value.CreateCopy();
viewpoint.Position = newPosition;
viewpoint.AlignDirection(newDirection);  // 可能受原有旋转影响
doc.CurrentViewpoint.CopyFrom(viewpoint);

// ✅ 正确创建新Rotation3D清除旋转状态
var viewpoint = doc.CurrentViewpoint.Value.CreateCopy();
viewpoint.Rotation = new Rotation3D();  // 清除旋转
viewpoint.AlignDirection(newDirection);  // 从标准状态开始
viewpoint.AlignUp(newUpVector);
doc.CurrentViewpoint.CopyFrom(viewpoint);

错误3使用错误的向上向量

// ❌ 错误假设世界Z轴是向上向量
Vector3D upVector = new Vector3D(0, 0, 1);

// ✅ 正确:使用模型的标准向上向量
Vector3D upVector = doc.FrontRightTopViewUpVector;

// 或者根据需要计算正确的向上向量
// 例如俯视图中Y轴可能是向上
Vector3D topViewUp = new Vector3D(0, 1, 0);

13.6 实用工具代码

/// <summary>
/// 视角控制工具类 - 基于实际项目经验
/// </summary>
public static class ViewpointHelper
{
    /// <summary>
    /// 聚焦到单个模型对象(精确控制占屏比例)
    /// </summary>
    public static void FocusOnModelItem(ModelItem item, double viewAngleDegrees, 
        double targetViewRatio)
    {
        if (item == null) return;
        
        var bounds = item.BoundingBox();
        Point3D targetCenter = bounds.Center;
        double targetSize = Math.Max(bounds.Size.X, bounds.Size.Y);
        
        // 计算相机距离
        double cameraDistance = CalculateCameraDistance(
            targetSize, viewAngleDegrees, targetViewRatio);
        
        // 使用模型标准视角
        Document doc = Application.ActiveDocument;
        Vector3D viewDir = doc.FrontRightTopViewVector;
        Vector3D upVector = doc.FrontRightTopViewUpVector;
        
        Point3D cameraPos = new Point3D(
            targetCenter.X - viewDir.X * cameraDistance,
            targetCenter.Y - viewDir.Y * cameraDistance,
            targetCenter.Z - viewDir.Z * cameraDistance
        );
        
        ApplyViewpoint(cameraPos, targetCenter, upVector, useAlignDirection: true);
    }
    
    /// <summary>
    /// 聚焦到碰撞点(两个对象的中间位置)
    /// </summary>
    public static void FocusOnCollision(ModelItem item1, ModelItem item2, 
        double viewAngleDegrees, double targetViewRatio)
    {
        var bounds1 = item1.BoundingBox();
        var bounds2 = item2.BoundingBox();
        
        // 计算碰撞中心(两个包围盒最近的点)
        Point3D collisionCenter = CalculateCollisionCenter(bounds1, bounds2);
        double maxSize = Math.Max(
            Math.Max(bounds1.Size.X, bounds1.Size.Y),
            Math.Max(bounds2.Size.X, bounds2.Size.Y)
        );
        
        double cameraDistance = CalculateCameraDistance(
            maxSize, viewAngleDegrees, 0.35);
        
        Document doc = Application.ActiveDocument;
        Vector3D viewDir = doc.FrontRightTopViewVector;
        Vector3D upVector = doc.FrontRightTopViewUpVector;
        
        Point3D cameraPos = new Point3D(
            collisionCenter.X - viewDir.X * cameraDistance,
            collisionCenter.Y - viewDir.Y * cameraDistance,
            collisionCenter.Z - viewDir.Z * cameraDistance
        );
        
        ApplyViewpoint(cameraPos, collisionCenter, upVector, useAlignDirection: true);
    }
    
    /// <summary>
    /// 聚焦到指定点和大小的区域ZoomBox法
    /// </summary>
    public static void FocusOnPosition(Point3D center, double size, 
        double marginRatio = 0.1)
    {
        Document doc = Application.ActiveDocument;
        
        // 创建目标包围盒
        double halfSize = size / 2.0;
        double margin = size * marginRatio;
        
        BoundingBox3D targetBox = new BoundingBox3D(
            new Point3D(center.X - halfSize - margin, center.Y - halfSize - margin, 
                center.Z - halfSize - margin),
            new Point3D(center.X + halfSize + margin, center.Y + halfSize + margin, 
                center.Z + halfSize + margin)
        );
        
        // 先设置视角方向
        Vector3D viewDir = doc.FrontRightTopViewVector;
        Vector3D upVector = doc.FrontRightTopViewUpVector;
        double distance = size * 2.0;
        
        Point3D cameraPos = new Point3D(
            center.X - viewDir.X * distance,
            center.Y - viewDir.Y * distance,
            center.Z - viewDir.Z * distance
        );
        
        ApplyViewpoint(cameraPos, center, upVector, useAlignDirection: false);
        
        // 使用ZoomBox确保可见
        doc.ActiveView.ZoomBox(targetBox);
    }
    
    // 私有辅助方法...
    private static double CalculateCameraDistance(double targetSize, double fovDegrees, 
        double ratio)
    {
        double fovRadians = fovDegrees * Math.PI / 180.0;
        return (targetSize / 2.0) / Math.Tan(fovRadians / 2.0) / ratio;
    }
    
    private static void ApplyViewpoint(Point3D cameraPos, Point3D target, 
        Vector3D upVector, bool useAlignDirection)
    {
        Document doc = Application.ActiveDocument;
        Viewpoint vp = doc.CurrentViewpoint.Value.CreateCopy();
        
        vp.Position = cameraPos;
        
        if (useAlignDirection)
        {
            Vector3D lookDir = new Vector3D(
                target.X - cameraPos.X,
                target.Y - cameraPos.Y,
                target.Z - cameraPos.Z);
            lookDir.Normalize();
            vp.AlignDirection(lookDir);
            vp.AlignUp(upVector);
        }
        else
        {
            vp.Rotation = new Rotation3D();
            vp.WorldUpVector = new UnitVector3D(upVector.X, upVector.Y, upVector.Z);
        }
        
        doc.CurrentViewpoint.CopyFrom(vp);
    }
}

13.7 视角控制最佳实践

  1. 选择合适的聚焦方法

    • 单个对象精确聚焦 → 距离计算法
    • 路径或区域显示 → ZoomBox法
    • 碰撞点查看 → 碰撞中心计算 + 距离法
  2. 使用模型标准视角向量

    • 不要硬编码向量值
    • 使用 doc.FrontRightTopViewVector 等API
  3. 理解ZoomBox的副作用

    • ZoomBox会覆盖相机距离设置
    • ZoomBox后不要再调整Position来微调距离
  4. 正确处理Rotation3D

    • 创建新视角时,先用 new Rotation3D() 清除旋转
    • 再使用 AlignDirection 设置精确方向
  5. 保存和恢复视角

    // 保存
    Viewpoint savedView = doc.CurrentViewpoint.Value.CreateCopy();
    
    // ... 修改视角 ...
    
    // 恢复
    doc.CurrentViewpoint.CopyFrom(savedView);
    

文档版本2026.2
最后更新:基于 ViewpointHelper 重构经验(聚焦功能实现)
适用版本Navisworks Manage 2026