高级模块插入后的再编辑问题,包括ROI的显示和调节,要支持实时调节

This commit is contained in:
zhengxuan.zhang
2026-05-21 11:17:10 +08:00
parent 6abe391450
commit 2d14954bd3
6 changed files with 488 additions and 14 deletions
@@ -0,0 +1,37 @@
using Prism.Events;
using System;
using System.Collections.Generic;
using System.Windows;
namespace XplorePlane.Events
{
/// <summary>
/// 请求在主视口画布上激活 ROI 编辑模式的事件。
/// 由 CNC 流水线编辑器发布,ViewportPanelView 订阅并操作 PolygonRoiCanvas。
/// </summary>
public sealed class CncRoiEditRequestedEvent : PubSubEvent<CncRoiEditRequestedPayload>
{
}
public class CncRoiEditRequestedPayload
{
/// <summary>已保存的 ROI 多边形顶点(图像坐标)。为空表示新建 ROI。</summary>
public IReadOnlyList<Point> ExistingPoints { get; set; }
/// <summary>
/// ROI 顶点变化时的回调,参数为最新的顶点列表。
/// 每次用户添加/移动顶点后调用,用于实时写回参数并触发预览。
/// </summary>
public Action<IReadOnlyList<Point>> OnPointsChanged { get; set; }
/// <summary>ROI 编辑结束(用户完成或取消)时的回调。</summary>
public Action OnEditFinished { get; set; }
}
/// <summary>
/// 请求停止 ROI 编辑模式(清理画布状态)。
/// </summary>
public sealed class CncRoiEditCancelledEvent : PubSubEvent
{
}
}
@@ -75,6 +75,10 @@ namespace XplorePlane.ViewModels.Cnc
SaveAsPipelineCommand = new DelegateCommand(async () => await SaveAsPipelineAsync());
LoadPipelineCommand = new DelegateCommand(async () => await LoadPipelineAsync());
EditRoiCommand = new DelegateCommand(ExecuteEditRoi, () => SelectedNodeIsAdvancedModule && !IsRoiEditing);
ClearRoiCommand = new DelegateCommand(ExecuteClearRoi, () => SelectedNodeIsAdvancedModule && SelectedNodeHasRoi);
FinishRoiCommand = new DelegateCommand(FinishRoiEdit, () => IsRoiEditing);
_editorViewModel.PropertyChanged += OnEditorPropertyChanged;
RefreshFromSelection();
@@ -91,6 +95,15 @@ namespace XplorePlane.ViewModels.Cnc
{
if (!SetProperty(ref _selectedNode, value))
return;
// 切换节点时停止当前 ROI 编辑
CancelRoiEdit();
RaisePropertyChanged(nameof(SelectedNodeIsAdvancedModule));
RaisePropertyChanged(nameof(SelectedNodeHasRoi));
RaisePropertyChanged(nameof(SelectedNodeRoiSummary));
// CanExecute 依赖 SelectedNode,必须手动通知
(EditRoiCommand as DelegateCommand)?.RaiseCanExecuteChanged();
(ClearRoiCommand as DelegateCommand)?.RaiseCanExecuteChanged();
}
}
@@ -623,5 +636,180 @@ namespace XplorePlane.ViewModels.Cnc
return jsonElement.ToString();
}
}
private static readonly HashSet<string> AdvancedModuleOperatorKeys = new(StringComparer.OrdinalIgnoreCase)
{
"BgaVoidRate",
"VoidMeasurement"
};
// ── ROI 内联编辑 ──────────────────────────────────────────────────────
private bool _isRoiEditing;
/// <summary>当前选中节点是否为支持 ROI 的高级模块算子</summary>
public bool SelectedNodeIsAdvancedModule =>
SelectedNode != null && AdvancedModuleOperatorKeys.Contains(SelectedNode.OperatorKey);
/// <summary>当前选中节点是否已有保存的 ROI 多边形</summary>
public bool SelectedNodeHasRoi => GetRoiPointCount(SelectedNode) >= 3;
/// <summary>ROI 摘要文字(如"多边形 ROI6 个顶点"</summary>
public string SelectedNodeRoiSummary
{
get
{
int count = GetRoiPointCount(SelectedNode);
if (count < 3) return "未设置 ROI(全图检测)";
return $"多边形 ROI{count} 个顶点";
}
}
/// <summary>是否正在编辑 ROI</summary>
public bool IsRoiEditing
{
get => _isRoiEditing;
private set
{
if (SetProperty(ref _isRoiEditing, value))
{
(EditRoiCommand as DelegateCommand)?.RaiseCanExecuteChanged();
(ClearRoiCommand as DelegateCommand)?.RaiseCanExecuteChanged();
(FinishRoiCommand as DelegateCommand)?.RaiseCanExecuteChanged();
}
}
}
public ICommand EditRoiCommand { get; }
public ICommand ClearRoiCommand { get; }
public ICommand FinishRoiCommand { get; }
private void InitRoiCommands()
{
// Commands are initialized in constructor via field initializers below
}
private void ExecuteEditRoi()
{
if (SelectedNode == null || !SelectedNodeIsAdvancedModule || _eventAggregator == null)
return;
IsRoiEditing = true;
StatusMessage = "ROI 编辑中:在图像上点击添加顶点,完成后点击「完成 ROI」";
var existingPoints = ReadRoiPoints(SelectedNode);
_eventAggregator.GetEvent<CncRoiEditRequestedEvent>().Publish(new CncRoiEditRequestedPayload
{
ExistingPoints = existingPoints,
OnPointsChanged = points => OnRoiPointsChanged(SelectedNode, points),
OnEditFinished = FinishRoiEdit
});
}
private void ExecuteClearRoi()
{
if (SelectedNode == null || !SelectedNodeIsAdvancedModule) return;
WriteRoiPoints(SelectedNode, null);
CancelRoiEdit();
PersistActiveModule("已清除 ROI,将使用全图检测");
RaiseRoiProperties();
}
private void OnRoiPointsChanged(PipelineNodeViewModel node, IReadOnlyList<System.Windows.Point> points)
{
if (node == null) return;
WriteRoiPoints(node, points);
PersistActiveModule($"ROI 已更新:{points?.Count ?? 0} 个顶点");
RaiseRoiProperties();
TriggerDebouncedPreview();
}
private void FinishRoiEdit()
{
IsRoiEditing = false;
StatusMessage = HasActiveModule
? $"正在编辑检测模块:{_activeModuleNode?.Name}"
: "请选择检测模块以编辑其流水线。";
RaiseRoiProperties();
}
private void CancelRoiEdit()
{
if (!_isRoiEditing) return;
IsRoiEditing = false;
_eventAggregator?.GetEvent<CncRoiEditCancelledEvent>().Publish();
}
private void RaiseRoiProperties()
{
RaisePropertyChanged(nameof(SelectedNodeHasRoi));
RaisePropertyChanged(nameof(SelectedNodeRoiSummary));
}
// ── ROI 参数读写 ──────────────────────────────────────────────────────
private static int GetRoiPointCount(PipelineNodeViewModel node)
{
if (node == null) return 0;
// BgaVoidRate 用 RoiMode + PolyCount
var roiModeParam = node.Parameters.FirstOrDefault(p => p.Name == "RoiMode");
if (roiModeParam != null)
{
if (!string.Equals(roiModeParam.Value?.ToString(), "Polygon", StringComparison.OrdinalIgnoreCase))
return 0;
}
var polyCountParam = node.Parameters.FirstOrDefault(p => p.Name == "PolyCount");
if (polyCountParam == null) return 0;
return Convert.ToInt32(polyCountParam.Value);
}
private static IReadOnlyList<System.Windows.Point> ReadRoiPoints(PipelineNodeViewModel node)
{
int count = GetRoiPointCount(node);
if (count < 3) return Array.Empty<System.Windows.Point>();
var points = new List<System.Windows.Point>(count);
for (int i = 0; i < count; i++)
{
var px = node.Parameters.FirstOrDefault(p => p.Name == $"PolyX{i}");
var py = node.Parameters.FirstOrDefault(p => p.Name == $"PolyY{i}");
double x = px != null ? Convert.ToDouble(px.Value) : 0;
double y = py != null ? Convert.ToDouble(py.Value) : 0;
points.Add(new System.Windows.Point(x, y));
}
return points;
}
private static void WriteRoiPoints(PipelineNodeViewModel node, IReadOnlyList<System.Windows.Point> points)
{
if (node == null) return;
int count = points?.Count >= 3 ? points.Count : 0;
// 更新 RoiModeBgaVoidRate 专用)
var roiModeParam = node.Parameters.FirstOrDefault(p => p.Name == "RoiMode");
if (roiModeParam != null)
roiModeParam.Value = count >= 3 ? "Polygon" : "None";
// 更新 PolyCount
var polyCountParam = node.Parameters.FirstOrDefault(p => p.Name == "PolyCount");
if (polyCountParam != null)
polyCountParam.Value = count;
// 更新坐标(最多 32 个点)
for (int i = 0; i < 32; i++)
{
var px = node.Parameters.FirstOrDefault(p => p.Name == $"PolyX{i}");
var py = node.Parameters.FirstOrDefault(p => p.Name == $"PolyY{i}");
double x = (points != null && i < points.Count) ? points[i].X : 0;
double y = (points != null && i < points.Count) ? points[i].Y : 0;
if (px != null) px.Value = (int)x;
if (py != null) py.Value = (int)y;
}
}
}
}
@@ -17,7 +17,6 @@ namespace XplorePlane.Views.ImageProcessing
Loaded += (s, e) =>
{
// 获取主界面的 RoiCanvas 传给 ViewModel
var mainWin = Owner as MainWindow;
if (mainWin != null)
{
@@ -26,32 +25,23 @@ namespace XplorePlane.Views.ImageProcessing
vm.SetCanvas(canvas);
}
// 从 MainViewModel 获取 CncEditorViewModel 引用
if (DataContext is BgaDetectionViewModel bgaVm && Owner?.DataContext is ViewModels.MainViewModel mainVm)
{
var cncEditorField = mainVm.GetType().GetField("_cncEditorViewModel",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (cncEditorField?.GetValue(mainVm) is CncEditorViewModel cncEditor)
{
bgaVm.SetCncEditorViewModel(cncEditor);
}
}
};
Closed += (s, e) =>
{
if (DataContext is BgaDetectionViewModel vm)
{
// 恢复右键菜单,但保留 ROI
vm.RestoreContextMenu();
}
};
}
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
{
@@ -298,6 +298,147 @@
FontWeight="Bold"
Foreground="#555"
Text="属性" />
<!-- ROI 编辑区(仅高级模块算子显示) -->
<Border
Margin="0,0,0,8"
Padding="8,6"
Background="#F0F7FF"
BorderBrush="#B0D4F1"
BorderThickness="1"
CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedNodeIsAdvancedModule}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<!-- 标题行 -->
<DockPanel Margin="0,0,0,4">
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11"
FontWeight="SemiBold"
Foreground="#1A5276"
Text="ROI 区域" />
</DockPanel>
<!-- ROI 摘要 -->
<TextBlock
Margin="0,0,0,6"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#555"
Text="{Binding SelectedNodeRoiSummary}"
TextWrapping="Wrap" />
<!-- 编辑中提示 -->
<Border
Margin="0,0,0,6"
Padding="6,4"
Background="#FFF3CD"
BorderBrush="#FFEAA7"
BorderThickness="1"
CornerRadius="3">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRoiEditing}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#856404"
Text="在图像上点击添加顶点,拖动顶点调整位置"
TextWrapping="Wrap" />
</Border>
<!-- 操作按钮 -->
<StackPanel Orientation="Horizontal">
<!-- 编辑 ROI 按钮(非编辑状态显示) -->
<Button
Height="24"
Margin="0,0,4,0"
Padding="8,0"
Command="{Binding EditRoiCommand}"
Cursor="Hand"
FontFamily="{StaticResource UiFont}"
FontSize="11"
ToolTip="在图像上绘制 ROI 多边形">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#2980B9" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Content" Value="✏ 编辑 ROI" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRoiEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- 完成 ROI 按钮(编辑状态显示) -->
<Button
Height="24"
Margin="0,0,4,0"
Padding="8,0"
Command="{Binding FinishRoiCommand}"
Cursor="Hand"
FontFamily="{StaticResource UiFont}"
FontSize="11"
ToolTip="完成 ROI 编辑">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#27AE60" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Content" Value="✔ 完成 ROI" />
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRoiEditing}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- 清除 ROI 按钮 -->
<Button
Height="24"
Padding="8,0"
Command="{Binding ClearRoiCommand}"
Cursor="Hand"
FontFamily="{StaticResource UiFont}"
FontSize="11"
ToolTip="清除 ROI,恢复全图检测">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#E74C3C" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Content" Value="✕ 清除" />
</Style>
</Button.Style>
</Button>
</StackPanel>
</StackPanel>
</Border>
<ItemsControl ItemsSource="{Binding SelectedNode.Parameters}">
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
@@ -25,15 +25,12 @@ namespace XplorePlane.Views.ImageProcessing
vm.SetCanvas(canvas);
}
// 从 MainViewModel 获取 CncEditorViewModel 引用
if (DataContext is VoidDetectionViewModel voidVm && Owner?.DataContext is ViewModels.MainViewModel mainVm)
{
var cncEditorField = mainVm.GetType().GetField("_cncEditorViewModel",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (cncEditorField?.GetValue(mainVm) is CncEditorViewModel cncEditor)
{
voidVm.SetCncEditorViewModel(cncEditor);
}
}
};
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Windows;
@@ -145,6 +146,17 @@ namespace XplorePlane.Views
}
catch { }
// 订阅 CNC ROI 内联编辑事件
try
{
var ea2 = ContainerLocator.Current?.Resolve<Prism.Events.IEventAggregator>();
ea2?.GetEvent<Events.CncRoiEditRequestedEvent>()
.Subscribe(OnCncRoiEditRequested, Prism.Events.ThreadOption.UIThread);
ea2?.GetEvent<Events.CncRoiEditCancelledEvent>()
.Subscribe(OnCncRoiEditCancelled, Prism.Events.ThreadOption.UIThread);
}
catch { }
// 光标信息:从 RoiCanvas.CursorInfo 同步到 MainViewModel
var cursorInfoDesc = System.ComponentModel.DependencyPropertyDescriptor.FromProperty(
PolygonRoiCanvas.CursorInfoProperty, typeof(PolygonRoiCanvas));
@@ -168,6 +180,115 @@ namespace XplorePlane.Views
// 测量模式和十字线通过 Prism 事件直接驱动,不再依赖 PropertyChanged
}
#region CNC ROI
private XP.ImageProcessing.RoiControl.Models.PolygonROI _cncRoiShape;
private Events.CncRoiEditRequestedPayload _cncRoiPayload;
private void OnCncRoiEditRequested(Events.CncRoiEditRequestedPayload payload)
{
_cncRoiPayload = payload;
// 清理旧的 ROI shape
CleanupCncRoi();
// 确保 ROIItems 集合存在
if (RoiCanvas.ROIItems == null)
RoiCanvas.ROIItems = new System.Collections.ObjectModel.ObservableCollection<XP.ImageProcessing.RoiControl.Models.ROIShape>();
// 创建新的多边形 ROI
_cncRoiShape = new XP.ImageProcessing.RoiControl.Models.PolygonROI
{
Color = "Cyan",
IsSelected = true
};
// 恢复已保存的顶点
if (payload.ExistingPoints != null)
{
foreach (var pt in payload.ExistingPoints)
_cncRoiShape.Points.Add(pt);
}
RoiCanvas.ROIItems.Add(_cncRoiShape);
RoiCanvas.SelectedROI = _cncRoiShape;
// 禁用右键菜单,启用画布点击添加顶点
RoiCanvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, false);
RoiCanvas.AddHandler(XP.ImageProcessing.RoiControl.Controls.PolygonRoiCanvas.CanvasClickedEvent,
new RoutedEventHandler(OnCncRoiCanvasClicked));
// 顶点变化时回调
_cncRoiShape.Points.CollectionChanged += OnCncRoiPointsCollectionChanged;
}
private void OnCncRoiEditCancelled()
{
CleanupCncRoi();
}
private void OnCncRoiCanvasClicked(object sender, RoutedEventArgs e)
{
if (_cncRoiShape == null || _cncRoiPayload == null) return;
if (e is XP.ImageProcessing.RoiControl.Controls.CanvasClickedEventArgs args)
{
InsertPointToPolygon(args.Position, _cncRoiShape.Points);
_cncRoiShape.IsSelected = true;
RoiCanvas.SelectedROI = _cncRoiShape;
}
}
private void OnCncRoiPointsCollectionChanged(object sender,
System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (_cncRoiPayload?.OnPointsChanged == null || _cncRoiShape == null) return;
_cncRoiPayload.OnPointsChanged(new List<System.Windows.Point>(_cncRoiShape.Points));
}
private void CleanupCncRoi()
{
if (_cncRoiShape != null)
{
_cncRoiShape.Points.CollectionChanged -= OnCncRoiPointsCollectionChanged;
RoiCanvas.ROIItems?.Remove(_cncRoiShape);
RoiCanvas.SelectedROI = null;
_cncRoiShape = null;
}
RoiCanvas.RemoveHandler(XP.ImageProcessing.RoiControl.Controls.PolygonRoiCanvas.CanvasClickedEvent,
new RoutedEventHandler(OnCncRoiCanvasClicked));
RoiCanvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, true);
_cncRoiPayload = null;
}
/// <summary>智能插入顶点:找到距离新点最近的边,将新点插入到该边的两个顶点之间</summary>
private static void InsertPointToPolygon(System.Windows.Point newPoint,
System.Collections.ObjectModel.ObservableCollection<System.Windows.Point> points)
{
if (points.Count < 2) { points.Add(newPoint); return; }
int insertIndex = 0;
double minDist = double.MaxValue;
double d = PointToSegmentDist(points[points.Count - 1], points[0], newPoint);
if (d < minDist) { minDist = d; insertIndex = 0; }
for (int i = 1; i < points.Count; i++)
{
d = PointToSegmentDist(points[i - 1], points[i], newPoint);
if (d < minDist) { minDist = d; insertIndex = i; }
}
points.Insert(insertIndex, newPoint);
}
private static double PointToSegmentDist(System.Windows.Point a, System.Windows.Point b, System.Windows.Point p)
{
double dx = b.X - a.X, dy = b.Y - a.Y;
double lenSq = dx * dx + dy * dy;
if (lenSq < 1e-10) return Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y));
double t = Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq, 0, 1);
double projX = a.X + t * dx, projY = a.Y + t * dy;
return Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY));
}
#endregion
#region
private void ZoomIn_Click(object sender, RoutedEventArgs e) => RoiCanvas.ZoomScale = Math.Min(10.0, RoiCanvas.ZoomScale * 1.2);