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
This commit is contained in:
tian 2026-06-02 23:11:11 +08:00
parent 922864ce4f
commit c7a1eeebb8
7 changed files with 440 additions and 0 deletions

View File

@ -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
### ✨ 新功能

View File

@ -106,6 +106,8 @@
<!-- Core - Main Plugin Files -->
<Compile Include="src\Core\MainPlugin.cs" />
<Compile Include="src\Core\PathClickToolPlugin.cs" />
<Compile Include="src\Core\FaceInferToolPlugin.cs" />
<Compile Include="src\Core\FaceInferResult.cs" />
<Compile Include="src\Core\PathInputMonitor.cs" />
<Compile Include="src\Core\PathPointRenderPlugin.cs" />
<!-- Core - Business Logic -->

View File

@ -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按需集成激活/切换逻辑

View File

@ -0,0 +1,35 @@
using Autodesk.Navisworks.Api;
namespace NavisworksTransport
{
/// <summary>
/// 快速取面结果。
/// 所有坐标为宿主坐标系。
/// </summary>
public class FaceInferResult
{
/// <summary>点击命中点(宿主坐标系)</summary>
public Point3D HitPoint { get; }
/// <summary>面法向量(已归一化,朝向相机)</summary>
public Vector3D Normal { get; }
/// <summary>命中的 ModelItem</summary>
public ModelItem ModelItem { get; }
/// <summary>推断是否成功</summary>
public bool IsValid { get; }
public FaceInferResult(Point3D hitPoint, Vector3D normal, ModelItem modelItem)
{
HitPoint = hitPoint;
Normal = normal;
ModelItem = modelItem;
IsValid = modelItem != null;
}
/// <summary>创建失败结果</summary>
public static FaceInferResult Invalid { get; } = new FaceInferResult(
new Point3D(), new Vector3D(), null);
}
}

View File

@ -0,0 +1,171 @@
using System;
using Autodesk.Navisworks.Api;
using Autodesk.Navisworks.Api.Plugins;
using NavisworksTransport.Utils;
namespace NavisworksTransport
{
/// <summary>
/// 快速取面工具。单点+像素邻域法推断点击位置所在面的法向量。
/// 与 PathClickToolPlugin 并列,互斥激活。
/// </summary>
[Plugin("FaceInferTool", "NavisworksTransport")]
public class FaceInferToolPlugin : ToolPlugin
{
/// <summary>像素偏移递进序列(共线时自动退到下一级)</summary>
private static readonly int[] PixelOffsets = { 2, 5, 10 };
/// <summary>最小叉积长度阈值(低于此值视为三点共线)</summary>
private const double MinCrossLength = 0.001;
/// <summary>
/// 面推断结果事件。命中物体时传递 FaceInferResult未命中时传递 Invalid。
/// </summary>
public static event EventHandler<FaceInferResult> FaceInferred;
/// <summary>
/// 获取插件的程序集路径(用于动态加载)
/// </summary>
public static string AssemblyPath =>
System.Reflection.Assembly.GetExecutingAssembly().Location;
/// <summary>
/// 允许外部代码手动触发 FaceInferred 事件
/// </summary>
public static void TriggerFaceInferred(object sender, FaceInferResult result)
{
FaceInferred?.Invoke(sender, result);
}
/// <summary>
/// 插件构造函数
/// </summary>
public FaceInferToolPlugin()
{
LogManager.Debug("[FaceInferTool] FaceInferToolPlugin 实例已创建");
}
/// <summary>
/// 鼠标按下事件 — 单次点击内做三次 PickItemFromPoint 推断面法向量
/// </summary>
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;
}
/// <summary>
/// 鼠标移动事件 — 暂不处理(保持轻量)
/// </summary>
public override bool MouseMove(View view, KeyModifiers modifiers, int x, int y, double timeOffset)
{
return false;
}
/// <summary>
/// 光标样式 — 使用 MarkupQuickPick 与取点工具的 Measure 形成视觉区分
/// </summary>
public override Cursor GetCursor(View view, KeyModifiers modifier)
{
return Cursor.MarkupAutoPoly;
}
/// <summary>
/// 核心面推断逻辑。
/// 在 (x, y) 处做三次 PickItemFromPoint中心+右下邻域),
/// 通过叉积计算法向量,γ 递进规避共线场景。
/// </summary>
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;
}
}
}

View File

@ -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; }
}
/// <summary>
/// 快速取面测试 — 激活 FaceInferToolPlugin用户在模型上点击后显示面法向量
/// </summary>
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<FaceInferResult> 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);
}, "面推断测试");
}
/// <summary>
/// 读取选中对象的Transform信息并测试旋转
/// </summary>

View File

@ -288,6 +288,11 @@ NavisworksTransport 系统管理页签视图 - 采用与其他页签一致的Nav
Command="{Binding CaptureRealObjectTransformCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="读取选中真实物体的原始Transform并在3D视图中可视化X/Y/Z三轴"/>
<Button Content="🧭 快速取面测试"
Command="{Binding TestFaceInferCommand}"
Style="{StaticResource ActionButtonStyle}"
ToolTip="单点+像素邻域法快速推断面法向量。点击后在模型面上点击。"/>
</StackPanel>
</StackPanel>
</Border>