From c7a1eeebb88c39defe59fa5b76ca8e2775a2b4e0 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Tue, 2 Jun 2026 23:11:11 +0800
Subject: [PATCH] feat: add FaceInferToolPlugin - single-click face normal
inference tool
- New FaceInferToolPlugin: pixel-neighborhood method with {2,5,10}px offsets
- New FaceInferResult data class
- Test button in SystemManagement page
- Cursor: MarkupAutoPoly (arrow + dashed box)
- Uses project-standard tools: FindNamedParentContainer, viewpoint.Position
- Design doc at doc/design/2026/face-infer-tool-design.md
---
CHANGELOG.md | 17 ++
TransportPlugin.csproj | 2 +
doc/design/2026/face-infer-tool-design.md | 111 ++++++++++++
src/Core/FaceInferResult.cs | 35 ++++
src/Core/FaceInferToolPlugin.cs | 171 ++++++++++++++++++
.../ViewModels/SystemManagementViewModel.cs | 99 ++++++++++
src/UI/WPF/Views/SystemManagementView.xaml | 5 +
7 files changed, 440 insertions(+)
create mode 100644 doc/design/2026/face-infer-tool-design.md
create mode 100644 src/Core/FaceInferResult.cs
create mode 100644 src/Core/FaceInferToolPlugin.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b21e854..8f9b1bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# NavisworksTransport 变更日志
+## [0.15.5] - 2026-06-02
+
+### ✨ 新功能
+
+- **快速取面工具 `FaceInferToolPlugin`**:新增独立 ToolPlugin,单点+像素邻域法快速推断点击面法向量。
+ - 算法:一次点击内做三次 `PickItemFromPoint`(中心、右侧 δ px、下侧 δ px),叉积求法向量,δ 递进 {2, 5, 10} px 规避共线
+ - 朝向统一:法向量自动翻转到指向相机一侧(`viewpoint.Position` 与命中点对比)
+ - 构件识别:沿 `ModelItemAnalysisHelper.FindNamedParentContainer` 向上查找命名容器
+ - 光标:`Cursor.MarkupAutoPoly`(箭头+虚线小方块),与取点工具的 `Measure` 十字光标区分
+ - 与 `PathClickToolPlugin` 并列,互斥激活,结构完全平行
+- **`FaceInferResult`**:结果数据类,包含命中点(宿主坐标)、归一化法向量、ModelItem
+- **系统管理功能测试按钮**:页签底部新增「🧭 快速取面测试」,点击后激活工具,用户点击模型面 → 弹出结果对话框
+
+### 📝 文档
+
+- 新增 `doc/design/2026/face-infer-tool-design.md` 设计文档
+
## [0.15.4] - 2026-06-01
### ✨ 新功能
diff --git a/TransportPlugin.csproj b/TransportPlugin.csproj
index daa497d..88dde49 100644
--- a/TransportPlugin.csproj
+++ b/TransportPlugin.csproj
@@ -106,6 +106,8 @@
+
+
diff --git a/doc/design/2026/face-infer-tool-design.md b/doc/design/2026/face-infer-tool-design.md
new file mode 100644
index 0000000..f3dd1ca
--- /dev/null
+++ b/doc/design/2026/face-infer-tool-design.md
@@ -0,0 +1,111 @@
+# 快速取面工具设计
+
+创建时间:2026-06-02
+
+## 目标
+
+新增一个独立 ToolPlugin,通过单点+像素邻域法快速推断鼠标点击位置所在面的法向量。
+和取点工具 `PathClickToolPlugin` 并列,互斥激活。
+
+## 核心算法
+
+单点+像素邻域法 —— 一次点击内做三次 `PickItemFromPoint`:
+
+```
+中心点: PickItemFromPoint(x, y) → p0
+右邻域: PickItemFromPoint(x+δ, y) → p1
+下邻域: PickItemFromPoint(x, y+δ) → p2
+
+v1 = p1 - p0
+v2 = p2 - p0
+normal = normalize(cross(v1, v2))
+```
+
+偏移量 δ 三级递进:2px → 5px → 10px,小偏移共线时自动退到更大偏移。
+
+## 新增文件
+
+| 文件 | 职责 |
+|---|---|
+| `src/Models/FaceInferResult.cs` | 结果数据类 |
+| `src/Core/FaceInferToolPlugin.cs` | ToolPlugin 主体 |
+
+不修改任何现有文件。
+
+## 架构
+
+```
+FaceInferToolPlugin : ToolPlugin
+│
+├── Plugin("FaceInferTool", "NavisworksTransport")
+│
+├── Static 成员
+│ ├── event FaceInferred # 外部订阅事件
+│ ├── AssemblyPath # 用于动态加载
+│ └── TriggerFaceInferred() # 允许外部手动触发
+│
+├── GetCursor → Cursor.MarkupQuickPick
+│
+├── MouseDown(x, y)
+│ ├── centerPick = PickItemFromPoint(x, y)
+│ ├── rightPick = PickItemFromPoint(x + δ, y)
+│ ├── downPick = PickItemFromPoint(x, y + δ)
+│ ├── 同构件校验 (ModelItem 比较)
+│ ├── 共线校验 (cross.Length < 0.001 → 退到下一级 δ)
+│ ├── 法向量归一化
+│ ├── 朝向统一 (dot with viewDir, 始终指向相机)
+│ └── 触发 FaceInferred 事件
+│
+└── MouseMove → 暂不处理(轻量原则)
+```
+
+## FaceInferResult 数据结构
+
+```csharp
+public class FaceInferResult
+{
+ public Point3D HitPoint { get; } // 命中点(宿主坐标系)
+ public Vector3D Normal { get; } // 面法向量(已归一化,朝向相机)
+ public ModelItem ModelItem { get; } // 命中的 ModelItem
+ public bool IsValid { get; } // 推断是否成功
+}
+```
+
+所有坐标均为**宿主坐标系**(`PickItemFromPoint` 原始输出)。
+
+## 激活方式
+
+与 `PathClickToolPlugin` 完全相同的流程,仅插件名不同:
+
+```csharp
+var assemblyPath = FaceInferToolPlugin.AssemblyPath;
+Application.Plugins.AddPluginAssembly(assemblyPath);
+var record = (ToolPluginRecord)Application.Plugins.FindPlugin("FaceInferTool.NavisworksTransport");
+var plugin = record.LoadPlugin();
+Application.MainDocument.Tool.SetCustomToolPlugin(plugin);
+```
+
+## 光标
+
+使用 `Cursor.MarkupQuickPick`。Navisworks `Cursor` 枚举无平行四边形,此为最接近的选项。
+
+## 与 PathClickToolPlugin 的关系
+
+- 两者都是 `ToolPlugin`,同一时刻只有一个激活
+- 互斥切换:取面时取点失活,取面结束切回取点
+- 切换流程与项目中已有的「导航工具 ↔ ClickTool」切换模式一致
+
+## 暂不实现
+
+| 项目 | 原因 |
+|---|---|
+| 透视近大远小动态 δ 调整 | 先验证精度,不够再加 |
+| 跨面距离检测 (maxDist) | 同构件判断已覆盖基本场景 |
+| 曲面曲率分析 | 不同业务需求 |
+| MouseMove 实时预览 | 保持轻量 |
+
+## 下一步
+
+1. 实现两个新文件
+2. 在已知平面上验证法向量精度(误差应在 1° 以内)
+3. 后续由调用方(ViewModel)按需集成激活/切换逻辑
diff --git a/src/Core/FaceInferResult.cs b/src/Core/FaceInferResult.cs
new file mode 100644
index 0000000..9176d68
--- /dev/null
+++ b/src/Core/FaceInferResult.cs
@@ -0,0 +1,35 @@
+using Autodesk.Navisworks.Api;
+
+namespace NavisworksTransport
+{
+ ///
+ /// 快速取面结果。
+ /// 所有坐标为宿主坐标系。
+ ///
+ public class FaceInferResult
+ {
+ /// 点击命中点(宿主坐标系)
+ public Point3D HitPoint { get; }
+
+ /// 面法向量(已归一化,朝向相机)
+ public Vector3D Normal { get; }
+
+ /// 命中的 ModelItem
+ public ModelItem ModelItem { get; }
+
+ /// 推断是否成功
+ public bool IsValid { get; }
+
+ public FaceInferResult(Point3D hitPoint, Vector3D normal, ModelItem modelItem)
+ {
+ HitPoint = hitPoint;
+ Normal = normal;
+ ModelItem = modelItem;
+ IsValid = modelItem != null;
+ }
+
+ /// 创建失败结果
+ public static FaceInferResult Invalid { get; } = new FaceInferResult(
+ new Point3D(), new Vector3D(), null);
+ }
+}
diff --git a/src/Core/FaceInferToolPlugin.cs b/src/Core/FaceInferToolPlugin.cs
new file mode 100644
index 0000000..df3e717
--- /dev/null
+++ b/src/Core/FaceInferToolPlugin.cs
@@ -0,0 +1,171 @@
+using System;
+using Autodesk.Navisworks.Api;
+using Autodesk.Navisworks.Api.Plugins;
+using NavisworksTransport.Utils;
+
+namespace NavisworksTransport
+{
+ ///
+ /// 快速取面工具。单点+像素邻域法推断点击位置所在面的法向量。
+ /// 与 PathClickToolPlugin 并列,互斥激活。
+ ///
+ [Plugin("FaceInferTool", "NavisworksTransport")]
+ public class FaceInferToolPlugin : ToolPlugin
+ {
+ /// 像素偏移递进序列(共线时自动退到下一级)
+ private static readonly int[] PixelOffsets = { 2, 5, 10 };
+
+ /// 最小叉积长度阈值(低于此值视为三点共线)
+ private const double MinCrossLength = 0.001;
+
+ ///
+ /// 面推断结果事件。命中物体时传递 FaceInferResult,未命中时传递 Invalid。
+ ///
+ public static event EventHandler FaceInferred;
+
+ ///
+ /// 获取插件的程序集路径(用于动态加载)
+ ///
+ public static string AssemblyPath =>
+ System.Reflection.Assembly.GetExecutingAssembly().Location;
+
+ ///
+ /// 允许外部代码手动触发 FaceInferred 事件
+ ///
+ public static void TriggerFaceInferred(object sender, FaceInferResult result)
+ {
+ FaceInferred?.Invoke(sender, result);
+ }
+
+ ///
+ /// 插件构造函数
+ ///
+ public FaceInferToolPlugin()
+ {
+ LogManager.Debug("[FaceInferTool] FaceInferToolPlugin 实例已创建");
+ }
+
+ ///
+ /// 鼠标按下事件 — 单次点击内做三次 PickItemFromPoint 推断面法向量
+ ///
+ public override bool MouseDown(View view, KeyModifiers modifiers, ushort button, int x, int y, double timeOffset)
+ {
+ if (button != 1) return false; // 只响应左键
+
+ LogManager.Debug("[FaceInferTool] ===== 鼠标点击 =====");
+ LogManager.Debug($"[FaceInferTool] 屏幕坐标: ({x}, {y})");
+
+ var result = InferFace(view, x, y);
+
+ if (result.IsValid)
+ {
+ LogManager.Debug($"[FaceInferTool] ✓ 面推断成功");
+ LogManager.Debug($"[FaceInferTool] 命中点: ({result.HitPoint.X:F3}, {result.HitPoint.Y:F3}, {result.HitPoint.Z:F3})");
+ LogManager.Debug($"[FaceInferTool] 法向量: ({result.Normal.X:F3}, {result.Normal.Y:F3}, {result.Normal.Z:F3})");
+ LogManager.Debug($"[FaceInferTool] 构件: {ModelItemAnalysisHelper.GetSafeDisplayName(result.ModelItem)}");
+ }
+ else
+ {
+ LogManager.Debug("[FaceInferTool] ✗ 面推断失败(未命中/跨构件/共线)");
+ }
+
+ FaceInferred?.Invoke(this, result);
+ return false;
+ }
+
+ ///
+ /// 鼠标移动事件 — 暂不处理(保持轻量)
+ ///
+ public override bool MouseMove(View view, KeyModifiers modifiers, int x, int y, double timeOffset)
+ {
+ return false;
+ }
+
+ ///
+ /// 光标样式 — 使用 MarkupQuickPick 与取点工具的 Measure 形成视觉区分
+ ///
+ public override Cursor GetCursor(View view, KeyModifiers modifier)
+ {
+ return Cursor.MarkupAutoPoly;
+ }
+
+ ///
+ /// 核心面推断逻辑。
+ /// 在 (x, y) 处做三次 PickItemFromPoint(中心+右下邻域),
+ /// 通过叉积计算法向量,γ 递进规避共线场景。
+ ///
+ private static FaceInferResult InferFace(View view, int x, int y)
+ {
+ // 拾取中心点
+ var centerPick = view.PickItemFromPoint(x, y);
+ if (centerPick == null)
+ return FaceInferResult.Invalid;
+
+ var p0 = centerPick.Point;
+
+ foreach (int offset in PixelOffsets)
+ {
+ var rightPick = view.PickItemFromPoint(x + offset, y);
+ if (rightPick == null) continue;
+
+ var downPick = view.PickItemFromPoint(x, y + offset);
+ if (downPick == null) continue;
+
+ // 必须命中同一构件
+ if (rightPick.ModelItem != centerPick.ModelItem ||
+ downPick.ModelItem != centerPick.ModelItem)
+ continue;
+
+ var p1 = rightPick.Point;
+ var p2 = downPick.Point;
+
+ var v1 = new Vector3D(p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z);
+ var v2 = new Vector3D(p2.X - p0.X, p2.Y - p0.Y, p2.Z - p0.Z);
+
+ var normal = Cross(v1, v2);
+ double len = Length(normal);
+ if (len < MinCrossLength)
+ {
+ LogManager.Debug($"[FaceInferTool] 偏移{offset}px 三点近似共线,尝试更大偏移");
+ continue; // 退到下一级偏移
+ }
+
+ // 归一化
+ normal = new Vector3D(normal.X / len, normal.Y / len, normal.Z / len);
+
+ // 统一法向量朝向:指向相机一侧
+ var camPos = view.Document.CurrentViewpoint.Value.Position;
+ var toCam = new Vector3D(camPos.X - p0.X, camPos.Y - p0.Y, camPos.Z - p0.Z);
+ if (Dot(normal, toCam) < 0)
+ {
+ normal = new Vector3D(-normal.X, -normal.Y, -normal.Z);
+ }
+
+ return new FaceInferResult(p0, normal, centerPick.ModelItem);
+ }
+
+ LogManager.Debug("[FaceInferTool] 所有偏移量均无法推断平面");
+ return FaceInferResult.Invalid;
+ }
+
+ // ---- Vector3D 辅助方法(避免依赖 Navisworks Vector3D 静态方法在不同版本的可用性差异)----
+
+ private static Vector3D Cross(Vector3D a, Vector3D b)
+ {
+ return new Vector3D(
+ a.Y * b.Z - a.Z * b.Y,
+ a.Z * b.X - a.X * b.Z,
+ a.X * b.Y - a.Y * b.X);
+ }
+
+ private static double Length(Vector3D v)
+ {
+ return Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
+ }
+
+ private static double Dot(Vector3D a, Vector3D b)
+ {
+ return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
+ }
+ }
+}
diff --git a/src/UI/WPF/ViewModels/SystemManagementViewModel.cs b/src/UI/WPF/ViewModels/SystemManagementViewModel.cs
index e873b06..cf624c2 100644
--- a/src/UI/WPF/ViewModels/SystemManagementViewModel.cs
+++ b/src/UI/WPF/ViewModels/SystemManagementViewModel.cs
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using System.Windows.Input;
using System.Windows.Threading;
using Autodesk.Navisworks.Api;
+using Autodesk.Navisworks.Api.Plugins;
using ComApiBridge = Autodesk.Navisworks.Api.ComApi.ComApiBridge;
using NavisworksTransport.UI.WPF.Collections;
using NavisworksTransport.Core;
@@ -184,6 +185,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
public ICommand CoordinateSystemExplorerCommand { get; private set; }
public ICommand CaptureRealObjectTransformCommand { get; private set; }
public ICommand DetectFragmentDefaultUpCommand { get; private set; }
+ public ICommand TestFaceInferCommand { get; private set; }
// 坐标系设置
@@ -339,6 +341,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
CoordinateSystemExplorerCommand = new RelayCommand(() => ExecuteCoordinateSystemExplorer());
CaptureRealObjectTransformCommand = new RelayCommand(() => ExecuteCaptureRealObjectTransform());
DetectFragmentDefaultUpCommand = new RelayCommand(() => ExecuteDetectFragmentDefaultUp());
+ TestFaceInferCommand = new RelayCommand(() => ExecuteTestFaceInfer());
// 初始化坐标系选项
@@ -2024,6 +2027,102 @@ namespace NavisworksTransport.UI.WPF.ViewModels
public Vector3 SampleTranslation { get; set; }
}
+ ///
+ /// 快速取面测试 — 激活 FaceInferToolPlugin,用户在模型上点击后显示面法向量
+ ///
+ private void ExecuteTestFaceInfer()
+ {
+ SafeExecute(() =>
+ {
+ var doc = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ if (doc == null || doc.IsClear)
+ {
+ System.Windows.MessageBox.Show(
+ "没有活动的文档!请先打开一个模型。",
+ "错误",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Error);
+ return;
+ }
+
+ // 加载并激活 FaceInferToolPlugin
+ var assemblyPath = FaceInferToolPlugin.AssemblyPath;
+ Application.Plugins.AddPluginAssembly(assemblyPath);
+ var record = (ToolPluginRecord)Application.Plugins.FindPlugin("FaceInferTool.NavisworksTransport");
+ if (record == null)
+ {
+ System.Windows.MessageBox.Show(
+ "无法找到 FaceInferTool 插件!",
+ "错误",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Error);
+ return;
+ }
+ var plugin = record.LoadPlugin();
+ if (plugin == null)
+ {
+ System.Windows.MessageBox.Show(
+ "FaceInferTool 插件加载失败!",
+ "错误",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Error);
+ return;
+ }
+
+ UpdateMainStatus("请在模型上点击一个面…");
+
+ // 一次性事件处理器:取到面后自动取消订阅并恢复工具
+ EventHandler handler = null;
+ handler = (sender, result) =>
+ {
+ FaceInferToolPlugin.FaceInferred -= handler;
+
+ // 恢复为选择工具
+ try
+ {
+ Application.MainDocument.Tool.Value = Tool.Select;
+ }
+ catch { }
+
+ if (!result.IsValid)
+ {
+ UpdateMainStatus("面推断失败:未命中有效面");
+ return;
+ }
+
+ var info = new StringBuilder();
+ info.AppendLine("=== 面推断结果 ===");
+ info.AppendLine();
+ var namedParent = ModelItemAnalysisHelper.FindNamedParentContainer(result.ModelItem);
+ var displayName = ModelItemAnalysisHelper.GetSafeDisplayName(namedParent);
+ info.AppendLine($"构件: {displayName}");
+ info.AppendLine($"命中点: ({result.HitPoint.X:F3}, {result.HitPoint.Y:F3}, {result.HitPoint.Z:F3})");
+ info.AppendLine($"法向量: ({result.Normal.X:F6}, {result.Normal.Y:F6}, {result.Normal.Z:F6})");
+
+ string message = info.ToString();
+ LogManager.Info("[面推断测试]\n" + message);
+
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ var dialog = new NavisworksTransport.UI.WPF.Views.CoordinateSystemResultDialog
+ {
+ Title = "面推断结果",
+ ResultText = message
+ };
+ DialogHelper.SetOwnerSafely(dialog);
+ dialog.ShowDialog();
+ });
+
+ var statusName = ModelItemAnalysisHelper.GetSafeDisplayName(namedParent);
+ UpdateMainStatus($"面推断完成 — {statusName}");
+ };
+
+ FaceInferToolPlugin.FaceInferred += handler;
+
+ Application.MainDocument.Tool.SetCustomToolPlugin(plugin);
+ }, "面推断测试");
+ }
+
///
/// 读取选中对象的Transform信息,并测试旋转
///
diff --git a/src/UI/WPF/Views/SystemManagementView.xaml b/src/UI/WPF/Views/SystemManagementView.xaml
index 94f3a06..8ca9d15 100644
--- a/src/UI/WPF/Views/SystemManagementView.xaml
+++ b/src/UI/WPF/Views/SystemManagementView.xaml
@@ -288,6 +288,11 @@ NavisworksTransport 系统管理页签视图 - 采用与其他页签一致的Nav
Command="{Binding CaptureRealObjectTransformCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="读取选中真实物体的原始Transform,并在3D视图中可视化X/Y/Z三轴"/>
+
+