From 7fdc6adb44f1bb464cc4dc7eedcadb828ea757e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Fri, 24 Apr 2026 14:36:52 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=8B=E9=87=8F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=EF=BC=9A=E7=82=B9=E7=82=B9=E8=B7=9D=E3=80=81?= =?UTF-8?q?=E7=82=B9=E7=BA=BF=E8=B7=9D=E3=80=81=E8=A7=92=E5=BA=A6=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=EF=BC=9B=E5=8D=81=E5=AD=97=E8=BE=85=E5=8A=A9=E7=BA=BF?= =?UTF-8?q?=EF=BC=9B=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=EF=BC=9B=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E8=87=AA=E9=80=82=E5=BA=94=E7=AA=97=E5=8F=A3=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../Controls/PolygonRoiCanvas.xaml.cs | 116 ++++++++++++++++-- .../Models/AngleGroup.cs | 86 +++++++++++++ .../Models/MeasureMode.cs | 3 +- XplorePlane/ViewModels/Main/MainViewModel.cs | 2 +- .../Views/Main/ViewportPanelView.xaml.cs | 15 ++- 6 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 XP.ImageProcessing.RoiControl/Models/AngleGroup.cs diff --git a/.gitignore b/.gitignore index 0b8391a..a13a714 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ bld/ lib/ XP.ImageProcessing/ ImageProcessing.sln +ExternalLibraries/ # 排除 Libs 目录中的 DLL 和 PDB 文件(但保留目录结构) XplorePlane/Libs/Hardware/*.dll diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs index 134be77..a95274f 100644 --- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs +++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs @@ -344,6 +344,7 @@ namespace XP.ImageProcessing.RoiControl.Controls private Canvas _measureOverlay; private readonly System.Collections.Generic.List _ppGroups = new(); private readonly System.Collections.Generic.List _ptlGroups = new(); + private readonly System.Collections.Generic.List _angleGroups = new(); // 点点距临时状态 private Ellipse _pendingDot; @@ -355,10 +356,16 @@ namespace XP.ImageProcessing.RoiControl.Controls private Line _ptlTempLine; private Point? _ptlTempL1, _ptlTempL2; + // 角度测量临时状态 + private int _angleClickCount; + private Ellipse _angleTempVDot, _angleTempADot; + private Line _angleTempLineA; + private Point? _angleTempV, _angleTempA; + // 拖拽状态 private Ellipse _mDraggingDot; - private object _mDraggingOwner; // MeasureGroup 或 PointToLineGroup - private string _mDraggingRole; // "Dot1","Dot2","DotL1","DotL2","DotP" + private object _mDraggingOwner; + private string _mDraggingRole; private static void OnMeasureModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { @@ -383,8 +390,11 @@ namespace XP.ImageProcessing.RoiControl.Controls if (_ptlTempDot1 != null) { _measureOverlay.Children.Remove(_ptlTempDot1); _ptlTempDot1 = null; } if (_ptlTempDot2 != null) { _measureOverlay.Children.Remove(_ptlTempDot2); _ptlTempDot2 = null; } if (_ptlTempLine != null) { _measureOverlay.Children.Remove(_ptlTempLine); _ptlTempLine = null; } - _ptlTempL1 = _ptlTempL2 = null; - _ptlClickCount = 0; + _ptlTempL1 = _ptlTempL2 = null; _ptlClickCount = 0; + if (_angleTempVDot != null) { _measureOverlay.Children.Remove(_angleTempVDot); _angleTempVDot = null; } + if (_angleTempADot != null) { _measureOverlay.Children.Remove(_angleTempADot); _angleTempADot = null; } + if (_angleTempLineA != null) { _measureOverlay.Children.Remove(_angleTempLineA); _angleTempLineA = null; } + _angleTempV = _angleTempA = null; _angleClickCount = 0; } private void EnsureMeasureOverlay() @@ -399,16 +409,17 @@ namespace XP.ImageProcessing.RoiControl.Controls private void RemoveMeasureOverlay() { if (_measureOverlay != null) { mainCanvas.Children.Remove(_measureOverlay); _measureOverlay = null; } - _ppGroups.Clear(); - _ptlGroups.Clear(); + _ppGroups.Clear(); _ptlGroups.Clear(); _angleGroups.Clear(); _pendingDot = null; _pendingPoint = null; _ptlTempDot1 = _ptlTempDot2 = null; _ptlTempLine = null; _ptlTempL1 = _ptlTempL2 = null; _ptlClickCount = 0; + _angleTempVDot = _angleTempADot = null; _angleTempLineA = null; + _angleTempV = _angleTempA = null; _angleClickCount = 0; _mDraggingDot = null; _mDraggingOwner = null; } public void ClearMeasurements() => RemoveMeasureOverlay(); - public int MeasureCount => _ppGroups.Count + _ptlGroups.Count; + public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count; // ── 点击分发 ── @@ -421,6 +432,8 @@ namespace XP.ImageProcessing.RoiControl.Controls HandlePointDistanceClick(pos); else if (CurrentMeasureMode == Models.MeasureMode.PointToLine) HandlePointToLineClick(pos); + else if (CurrentMeasureMode == Models.MeasureMode.Angle) + HandleAngleClick(pos); } // ── 点点距 ── @@ -523,6 +536,65 @@ namespace XP.ImageProcessing.RoiControl.Controls return g; } + // ── 角度测量 ── + + private void HandleAngleClick(Point pos) + { + _angleClickCount++; + + if (_angleClickCount == 1) + { + _angleTempV = pos; + _angleTempVDot = CreateMDot(Brushes.Red); + _measureOverlay.Children.Add(_angleTempVDot); + SetDotPos(_angleTempVDot, pos); + RaiseMeasureStatusChanged($"角度测量 - 顶点: ({pos.X:F0}, {pos.Y:F0}),请点击射线端点A"); + } + else if (_angleClickCount == 2) + { + _angleTempA = pos; + _angleTempADot = CreateMDot(Brushes.Orange); + _measureOverlay.Children.Add(_angleTempADot); + SetDotPos(_angleTempADot, pos); + _angleTempLineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false, + X1 = _angleTempV.Value.X, Y1 = _angleTempV.Value.Y, X2 = pos.X, Y2 = pos.Y }; + _measureOverlay.Children.Add(_angleTempLineA); + RaiseMeasureStatusChanged($"角度测量 - 射线A已定义,请点击射线端点B"); + } + else if (_angleClickCount == 3) + { + var g = CreateAngleGroup(_angleTempV.Value, _angleTempA.Value, pos); + _angleGroups.Add(g); + + // 移除临时元素 + if (_angleTempVDot != null) _measureOverlay.Children.Remove(_angleTempVDot); + if (_angleTempADot != null) _measureOverlay.Children.Remove(_angleTempADot); + if (_angleTempLineA != null) _measureOverlay.Children.Remove(_angleTempLineA); + _angleTempVDot = _angleTempADot = null; _angleTempLineA = null; + _angleTempV = _angleTempA = null; _angleClickCount = 0; + + RaiseMeasureCompleted(g.V, g.B, g.AngleDeg, MeasureCount, "Angle"); + CurrentMeasureMode = Models.MeasureMode.None; + } + } + + private Models.AngleGroup CreateAngleGroup(Point v, Point a, Point b) + { + var g = new Models.AngleGroup { V = v, A = a, B = b }; + g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; + g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; + g.Arc = new Path { Stroke = Brushes.Yellow, StrokeThickness = 1.5, IsHitTestVisible = false }; + g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false }; + g.DotV = CreateMDot(Brushes.Red); + g.DotA = CreateMDot(Brushes.Orange); + g.DotB = CreateMDot(Brushes.Cyan); + foreach (UIElement el in new UIElement[] { g.LineA, g.LineB, g.Arc, g.Label, g.DotV, g.DotA, g.DotB }) + _measureOverlay.Children.Add(el); + SetDotPos(g.DotV, v); SetDotPos(g.DotA, a); SetDotPos(g.DotB, b); + g.UpdateVisuals(); + return g; + } + // ── 共用:圆点创建、定位、拖拽、删除 ── private Ellipse CreateMDot(Brush fill) @@ -561,6 +633,16 @@ namespace XP.ImageProcessing.RoiControl.Controls if (g.DotP == dot) { _mDraggingOwner = g; _mDraggingRole = "DotP"; break; } } } + // 查找角度组 + if (_mDraggingOwner == null) + { + foreach (var g in _angleGroups) + { + if (g.DotV == dot) { _mDraggingOwner = g; _mDraggingRole = "DotV"; break; } + if (g.DotA == dot) { _mDraggingOwner = g; _mDraggingRole = "DotA"; break; } + if (g.DotB == dot) { _mDraggingOwner = g; _mDraggingRole = "DotB"; break; } + } + } if (_mDraggingOwner != null) { _mDraggingDot = dot; dot.CaptureMouse(); e.Handled = true; } } @@ -586,6 +668,14 @@ namespace XP.ImageProcessing.RoiControl.Controls var foot = ptlg.FootPoint; RaiseMeasureCompleted(ptlg.P, foot, ptlg.Distance, MeasureCount, "PointToLine"); } + else if (_mDraggingOwner is Models.AngleGroup ag) + { + if (_mDraggingRole == "DotV") ag.V = pos; + else if (_mDraggingRole == "DotA") ag.A = pos; + else if (_mDraggingRole == "DotB") ag.B = pos; + ag.UpdateVisuals(); + RaiseMeasureCompleted(ag.V, ag.B, ag.AngleDeg, MeasureCount, "Angle"); + } e.Handled = true; } @@ -622,6 +712,18 @@ namespace XP.ImageProcessing.RoiControl.Controls e.Handled = true; return; } } + // 角度删除 + foreach (var g in _angleGroups) + { + if (g.DotV == dot || g.DotA == dot || g.DotB == dot) + { + foreach (var el in new UIElement[] { g.DotV, g.DotA, g.DotB, g.LineA, g.LineB, g.Arc, g.Label }) + _measureOverlay.Children.Remove(el); + _angleGroups.Remove(g); + RaiseMeasureStatusChanged($"已删除测量 | 剩余 {MeasureCount} 条"); + e.Handled = true; return; + } + } } // ── 事件 ── diff --git a/XP.ImageProcessing.RoiControl/Models/AngleGroup.cs b/XP.ImageProcessing.RoiControl/Models/AngleGroup.cs new file mode 100644 index 0000000..85a3077 --- /dev/null +++ b/XP.ImageProcessing.RoiControl/Models/AngleGroup.cs @@ -0,0 +1,86 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace XP.ImageProcessing.RoiControl.Models +{ + /// 一次角度测量的所有视觉元素(顶点V + 射线端点A/B + 两条射线 + 弧线 + 标签) + public class AngleGroup + { + public Ellipse DotV { get; set; } // 顶点 + public Ellipse DotA { get; set; } // 射线端点A + public Ellipse DotB { get; set; } // 射线端点B + public Line LineA { get; set; } // 射线VA + public Line LineB { get; set; } // 射线VB + public Path Arc { get; set; } // 角度弧线 + public TextBlock Label { get; set; } + public Point V { get; set; } + public Point A { get; set; } + public Point B { get; set; } + + public double AngleDeg + { + get + { + double vax = A.X - V.X, vay = A.Y - V.Y; + double vbx = B.X - V.X, vby = B.Y - V.Y; + double lenA = Math.Sqrt(vax * vax + vay * vay); + double lenB = Math.Sqrt(vbx * vbx + vby * vby); + if (lenA < 0.001 || lenB < 0.001) return 0; + double dot = vax * vbx + vay * vby; + double cos = Math.Clamp(dot / (lenA * lenB), -1.0, 1.0); + return Math.Acos(cos) * 180.0 / Math.PI; + } + } + + public void UpdateVisuals() + { + // 射线 + LineA.X1 = V.X; LineA.Y1 = V.Y; LineA.X2 = A.X; LineA.Y2 = A.Y; + LineA.Visibility = Visibility.Visible; + LineB.X1 = V.X; LineB.Y1 = V.Y; LineB.X2 = B.X; LineB.Y2 = B.Y; + LineB.Visibility = Visibility.Visible; + + // 弧线 + double vax = A.X - V.X, vay = A.Y - V.Y; + double vbx = B.X - V.X, vby = B.Y - V.Y; + double lenA = Math.Sqrt(vax * vax + vay * vay); + double lenB = Math.Sqrt(vbx * vbx + vby * vby); + + if (lenA < 1 || lenB < 1) { Arc.Visibility = Visibility.Collapsed; return; } + + double arcRadius = Math.Min(30, Math.Min(lenA, lenB) * 0.3); + if (arcRadius < 8) arcRadius = 8; + + double angleARad = Math.Atan2(vay, vax); + double angleBRad = Math.Atan2(vby, vbx); + double cross = vax * vby - vay * vbx; + double angleDeg = AngleDeg; + bool isLargeArc = angleDeg > 180; + var sweepDir = cross >= 0 ? SweepDirection.Clockwise : SweepDirection.Counterclockwise; + + var startPt = new Point(V.X + arcRadius * Math.Cos(angleARad), V.Y + arcRadius * Math.Sin(angleARad)); + var endPt = new Point(V.X + arcRadius * Math.Cos(angleBRad), V.Y + arcRadius * Math.Sin(angleBRad)); + + var arcSeg = new ArcSegment(endPt, new Size(arcRadius, arcRadius), 0, isLargeArc, sweepDir, true); + var fig = new PathFigure(startPt, new[] { arcSeg }, false); + Arc.Data = new PathGeometry(new[] { fig }); + Arc.Visibility = Visibility.Visible; + + // 标签位置 + double midAngle = (angleARad + angleBRad) / 2.0; + double testX = Math.Cos(midAngle), testY = Math.Sin(midAngle); + double testCross = vax * testY - vay * testX; + if ((cross >= 0 && testCross < 0) || (cross < 0 && testCross >= 0)) + midAngle += Math.PI; + + double labelDist = arcRadius + 16; + Label.Text = $"{angleDeg:F1}°"; + Canvas.SetLeft(Label, V.X + labelDist * Math.Cos(midAngle) - 15); + Canvas.SetTop(Label, V.Y + labelDist * Math.Sin(midAngle) - 8); + Label.Visibility = Visibility.Visible; + } + } +} diff --git a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs index c21bce4..5de1d9d 100644 --- a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs +++ b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs @@ -4,6 +4,7 @@ namespace XP.ImageProcessing.RoiControl.Models { None, PointDistance, - PointToLine + PointToLine, + Angle } } diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index d88fe5f..a2f8c9d 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -455,7 +455,7 @@ namespace XplorePlane.ViewModels private void ExecuteAngleMeasure() { _logger.Info("角度测量功能已触发"); - // TODO: 实现角度测量逻辑 + _eventAggregator.GetEvent().Publish(MeasurementToolMode.Angle); } private void ExecuteThroughHoleFillRateMeasure() diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs index bd03651..9438a2f 100644 --- a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs +++ b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs @@ -25,9 +25,17 @@ namespace XplorePlane.Views { if (e is MeasureCompletedEventArgs args && DataContext is ViewportPanelViewModel vm) { - vm.MeasurementResult = $"{args.Distance:F2} px"; - string typeLabel = args.MeasureType == "PointToLine" ? "点线距" : "点点距"; - vm.ImageInfo = $"{typeLabel}: {args.Distance:F2} px | ({args.P1.X:F0},{args.P1.Y:F0}) → ({args.P2.X:F0},{args.P2.Y:F0}) | 共 {args.TotalCount} 条测量"; + string typeLabel = args.MeasureType switch + { + "PointToLine" => "点线距", + "Angle" => "角度", + _ => "点点距" + }; + string valueText = args.MeasureType == "Angle" + ? $"{args.Distance:F2}°" + : $"{args.Distance:F2} px"; + vm.MeasurementResult = valueText; + vm.ImageInfo = $"{typeLabel}: {valueText} | 共 {args.TotalCount} 条测量"; } }; RoiCanvas.MeasureStatusChanged += (s, e) => @@ -52,6 +60,7 @@ namespace XplorePlane.Views { MeasurementToolMode.PointDistance => XP.ImageProcessing.RoiControl.Models.MeasureMode.PointDistance, MeasurementToolMode.PointLineDistance => XP.ImageProcessing.RoiControl.Models.MeasureMode.PointToLine, + MeasurementToolMode.Angle => XP.ImageProcessing.RoiControl.Models.MeasureMode.Angle, _ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None }; }, Prism.Events.ThreadOption.UIThread); From faa4e61d9823a985170ca724f7a825608a10706b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Fri, 24 Apr 2026 15:38:25 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E9=9B=86=E6=88=90=E9=80=9A=E5=AD=94?= =?UTF-8?q?=E5=A1=AB=E9=94=A1=E7=8E=87=E6=B5=8B=E9=87=8F=EF=BC=9A=E5=9B=9B?= =?UTF-8?q?=E6=A4=AD=E5=9C=86=E4=BA=A4=E4=BA=92=E3=80=81=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E3=80=81=E9=95=BF=E7=9F=AD=E8=BD=B4=E6=89=8B?= =?UTF-8?q?=E6=9F=84=E8=B0=83=E6=95=B4=E3=80=81=E5=A1=AB=E9=94=A1=E7=8E=87?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E8=AE=A1=E7=AE=97=E4=B8=8EPASS/FAIL=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controls/PolygonRoiCanvas.xaml.cs | 129 ++++++++++++++- .../Models/FillRateGroup.cs | 148 ++++++++++++++++++ .../Models/MeasureMode.cs | 3 +- XplorePlane/ViewModels/Main/MainViewModel.cs | 9 +- 4 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 XP.ImageProcessing.RoiControl/Models/FillRateGroup.cs diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs index a95274f..716eede 100644 --- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs +++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs @@ -345,6 +345,7 @@ namespace XP.ImageProcessing.RoiControl.Controls private readonly System.Collections.Generic.List _ppGroups = new(); private readonly System.Collections.Generic.List _ptlGroups = new(); private readonly System.Collections.Generic.List _angleGroups = new(); + private readonly System.Collections.Generic.List _frGroups = new(); // 点点距临时状态 private Ellipse _pendingDot; @@ -409,7 +410,7 @@ namespace XP.ImageProcessing.RoiControl.Controls private void RemoveMeasureOverlay() { if (_measureOverlay != null) { mainCanvas.Children.Remove(_measureOverlay); _measureOverlay = null; } - _ppGroups.Clear(); _ptlGroups.Clear(); _angleGroups.Clear(); + _ppGroups.Clear(); _ptlGroups.Clear(); _angleGroups.Clear(); _frGroups.Clear(); _pendingDot = null; _pendingPoint = null; _ptlTempDot1 = _ptlTempDot2 = null; _ptlTempLine = null; _ptlTempL1 = _ptlTempL2 = null; _ptlClickCount = 0; @@ -419,7 +420,7 @@ namespace XP.ImageProcessing.RoiControl.Controls } public void ClearMeasurements() => RemoveMeasureOverlay(); - public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count; + public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count + _frGroups.Count; // ── 点击分发 ── @@ -434,6 +435,8 @@ namespace XP.ImageProcessing.RoiControl.Controls HandlePointToLineClick(pos); else if (CurrentMeasureMode == Models.MeasureMode.Angle) HandleAngleClick(pos); + else if (CurrentMeasureMode == Models.MeasureMode.FillRate) + HandleFillRateClick(pos); } // ── 点点距 ── @@ -595,6 +598,77 @@ namespace XP.ImageProcessing.RoiControl.Controls return g; } + // ── 通孔填锡率 ── + + private void HandleFillRateClick(Point pos) + { + // 单击放置:自动生成4个椭圆的初始位置 + double offset = 70; + var e1 = new Point(pos.X, pos.Y + offset / 2); + var e2 = new Point(pos.X + 20, pos.Y - offset / 2); + var e3 = e1; // 起点=底部 + var e4 = new Point(pos.X + 10, pos.Y - offset / 4); + + var g = CreateFillRateGroup(e1, e2, e3, e4); + _frGroups.Add(g); + + double rate = g.FillRate; + RaiseMeasureCompleted(g.E3, g.E4, rate, MeasureCount, "FillRate"); + CurrentMeasureMode = Models.MeasureMode.None; + } + + private Models.FillRateGroup CreateFillRateGroup(Point e1, Point e2, Point e3, Point e4) + { + var g = new Models.FillRateGroup { E1 = e1, E2 = e2, E3 = e3, E4 = e4 }; + + g.PathE1 = CreateEllipsePath(Brushes.DodgerBlue, false); + g.PathE2 = CreateEllipsePath(Brushes.Cyan, false); + g.PathE3 = CreateEllipsePath(Brushes.Yellow, true); + g.PathE4 = CreateEllipsePath(Brushes.Lime, false); + + g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = 1, IsHitTestVisible = false }; + g.FillLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 2, IsHitTestVisible = false }; + + g.Label = new TextBlock { FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false }; + + g.DotE1 = CreateMDot(Brushes.DodgerBlue); + g.DotE2 = CreateMDot(Brushes.Cyan); + g.DotE3 = CreateMDot(Brushes.Yellow); + g.DotE4 = CreateMDot(Brushes.Lime); + + // 轴手柄(小方块样式区分) + g.E1AH = CreateAxisHandle(Brushes.DodgerBlue); g.E1BH = CreateAxisHandle(Brushes.DodgerBlue); + g.E2AH = CreateAxisHandle(Brushes.Cyan); g.E2BH = CreateAxisHandle(Brushes.Cyan); + g.E3AH = CreateAxisHandle(Brushes.Yellow); g.E3BH = CreateAxisHandle(Brushes.Yellow); + g.E4AH = CreateAxisHandle(Brushes.Lime); g.E4BH = CreateAxisHandle(Brushes.Lime); + + foreach (var el in g.AllElements) + _measureOverlay.Children.Add(el); + + SetDotPos(g.DotE1, e1); SetDotPos(g.DotE2, e2); + SetDotPos(g.DotE3, e3); SetDotPos(g.DotE4, e4); + g.UpdateVisuals(); + return g; + } + + private Ellipse CreateAxisHandle(Brush fill) + { + var h = new Ellipse { Width = 8, Height = 8, Fill = fill, Stroke = Brushes.White, StrokeThickness = 1, Cursor = Cursors.SizeAll }; + h.SetValue(ContextMenuService.IsEnabledProperty, false); + h.MouseLeftButtonDown += MDot_Down; + h.MouseMove += MDot_Move; + h.MouseLeftButtonUp += MDot_Up; + h.PreviewMouseRightButtonUp += MDot_RightClick; + return h; + } + + private static Path CreateEllipsePath(Brush stroke, bool dashed) + { + var p = new Path { Stroke = stroke, StrokeThickness = 1.5, IsHitTestVisible = false }; + if (dashed) p.StrokeDashArray = new DoubleCollection { 4, 2 }; + return p; + } + // ── 共用:圆点创建、定位、拖拽、删除 ── private Ellipse CreateMDot(Brush fill) @@ -643,6 +717,25 @@ namespace XP.ImageProcessing.RoiControl.Controls if (g.DotB == dot) { _mDraggingOwner = g; _mDraggingRole = "DotB"; break; } } } + // 查找填锡率组 + if (_mDraggingOwner == null) + { + foreach (var g in _frGroups) + { + if (g.DotE1 == dot) { _mDraggingOwner = g; _mDraggingRole = "E1"; break; } + if (g.DotE2 == dot) { _mDraggingOwner = g; _mDraggingRole = "E2"; break; } + if (g.DotE3 == dot) { _mDraggingOwner = g; _mDraggingRole = "E3"; break; } + if (g.DotE4 == dot) { _mDraggingOwner = g; _mDraggingRole = "E4"; break; } + if (g.E1AH == dot) { _mDraggingOwner = g; _mDraggingRole = "E1A"; break; } + if (g.E1BH == dot) { _mDraggingOwner = g; _mDraggingRole = "E1B"; break; } + if (g.E2AH == dot) { _mDraggingOwner = g; _mDraggingRole = "E2A"; break; } + if (g.E2BH == dot) { _mDraggingOwner = g; _mDraggingRole = "E2B"; break; } + if (g.E3AH == dot) { _mDraggingOwner = g; _mDraggingRole = "E3A"; break; } + if (g.E3BH == dot) { _mDraggingOwner = g; _mDraggingRole = "E3B"; break; } + if (g.E4AH == dot) { _mDraggingOwner = g; _mDraggingRole = "E4A"; break; } + if (g.E4BH == dot) { _mDraggingOwner = g; _mDraggingRole = "E4B"; break; } + } + } if (_mDraggingOwner != null) { _mDraggingDot = dot; dot.CaptureMouse(); e.Handled = true; } } @@ -676,6 +769,23 @@ namespace XP.ImageProcessing.RoiControl.Controls ag.UpdateVisuals(); RaiseMeasureCompleted(ag.V, ag.B, ag.AngleDeg, MeasureCount, "Angle"); } + else if (_mDraggingOwner is Models.FillRateGroup frg) + { + if (_mDraggingRole == "E1") frg.E1 = pos; + else if (_mDraggingRole == "E2") frg.E2 = pos; + else if (_mDraggingRole == "E3") frg.E3 = pos; + else if (_mDraggingRole == "E4") frg.E4 = pos; + else if (_mDraggingRole == "E1A") frg.E1A = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E1)); + else if (_mDraggingRole == "E1B") frg.E1B = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E1)); + else if (_mDraggingRole == "E2A") frg.E2A = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E2)); + else if (_mDraggingRole == "E2B") frg.E2B = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E2)); + else if (_mDraggingRole == "E3A") frg.E3A = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E3)); + else if (_mDraggingRole == "E3B") frg.E3B = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E3)); + else if (_mDraggingRole == "E4A") frg.E4A = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E4)); + else if (_mDraggingRole == "E4B") frg.E4B = Math.Max(10, Models.FillRateGroup.Dist(pos, frg.E4)); + frg.UpdateVisuals(); + RaiseMeasureCompleted(frg.E3, frg.E4, frg.FillRate, MeasureCount, "FillRate"); + } e.Handled = true; } @@ -724,6 +834,21 @@ namespace XP.ImageProcessing.RoiControl.Controls e.Handled = true; return; } } + // 填锡率删除 + foreach (var g in _frGroups) + { + bool match = g.DotE1 == dot || g.DotE2 == dot || g.DotE3 == dot || g.DotE4 == dot + || g.E1AH == dot || g.E1BH == dot || g.E2AH == dot || g.E2BH == dot + || g.E3AH == dot || g.E3BH == dot || g.E4AH == dot || g.E4BH == dot; + if (match) + { + foreach (var el in g.AllElements) + _measureOverlay.Children.Remove(el); + _frGroups.Remove(g); + RaiseMeasureStatusChanged($"已删除测量 | 剩余 {MeasureCount} 条"); + e.Handled = true; return; + } + } } // ── 事件 ── diff --git a/XP.ImageProcessing.RoiControl/Models/FillRateGroup.cs b/XP.ImageProcessing.RoiControl/Models/FillRateGroup.cs new file mode 100644 index 0000000..3b2d6b1 --- /dev/null +++ b/XP.ImageProcessing.RoiControl/Models/FillRateGroup.cs @@ -0,0 +1,148 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace XP.ImageProcessing.RoiControl.Models +{ + /// 通孔填锡率测量组(4个椭圆中心 + 连线 + 标签) + public class FillRateGroup + { + // 四个椭圆中心的可拖拽圆点 + public Ellipse DotE1 { get; set; } // 底部(蓝) + public Ellipse DotE2 { get; set; } // 顶部(青) + public Ellipse DotE3 { get; set; } // 填锡起点(黄) + public Ellipse DotE4 { get; set; } // 填锡终点(绿) + + // 长轴/短轴手柄(每个椭圆2个) + public Ellipse E1AH { get; set; } + public Ellipse E1BH { get; set; } + public Ellipse E2AH { get; set; } + public Ellipse E2BH { get; set; } + public Ellipse E3AH { get; set; } + public Ellipse E3BH { get; set; } + public Ellipse E4AH { get; set; } + public Ellipse E4BH { get; set; } + + // 椭圆路径 + public Path PathE1 { get; set; } + public Path PathE2 { get; set; } + public Path PathE3 { get; set; } + public Path PathE4 { get; set; } + + // 连线:E1→E2(全高度),E3→E4(填锡高度) + public Line FullLine { get; set; } + public Line FillLine { get; set; } + + public TextBlock Label { get; set; } + + // 四个椭圆中心坐标 + public Point E1 { get; set; } + public Point E2 { get; set; } + public Point E3 { get; set; } + public Point E4 { get; set; } + + // 椭圆轴参数(默认值,后续可拖拽调整) + public double E1A { get; set; } = 60; + public double E1B { get; set; } = 50; + public double E1Angle { get; set; } + public double E2A { get; set; } = 60; + public double E2B { get; set; } = 50; + public double E2Angle { get; set; } + public double E3A { get; set; } = 60; + public double E3B { get; set; } = 50; + public double E3Angle { get; set; } + public double E4A { get; set; } = 55; + public double E4B { get; set; } = 45; + public double E4Angle { get; set; } + + public double THTLimit { get; set; } = 75.0; + + public double FillRate + { + get + { + double fullDx = E2.X - E1.X, fullDy = E2.Y - E1.Y; + double fullDist = Math.Sqrt(fullDx * fullDx + fullDy * fullDy); + double fillDx = E4.X - E3.X, fillDy = E4.Y - E3.Y; + double fillDist = Math.Sqrt(fillDx * fillDx + fillDy * fillDy); + return fullDist > 0 ? Math.Clamp(fillDist / fullDist * 100.0, 0, 100) : 0; + } + } + + public string Classification => FillRate >= THTLimit ? "PASS" : "FAIL"; + + public void UpdateVisuals() + { + UpdateEllipsePath(PathE1, E1, E1A, E1B, E1Angle); + UpdateEllipsePath(PathE2, E2, E2A, E2B, E2Angle); + UpdateEllipsePath(PathE3, E3, E3A, E3B, E3Angle); + UpdateEllipsePath(PathE4, E4, E4A, E4B, E4Angle); + + FullLine.X1 = E1.X; FullLine.Y1 = E1.Y; + FullLine.X2 = E2.X; FullLine.Y2 = E2.Y; + FullLine.Visibility = Visibility.Visible; + + FillLine.X1 = E3.X; FillLine.Y1 = E3.Y; + FillLine.X2 = E4.X; FillLine.Y2 = E4.Y; + FillLine.Visibility = Visibility.Visible; + + // 轴手柄定位 + SetHandle(E1AH, EdgePoint(E1, E1A, E1Angle)); + SetHandle(E1BH, EdgePoint(E1, E1B, E1Angle + 90)); + SetHandle(E2AH, EdgePoint(E2, E2A, E2Angle)); + SetHandle(E2BH, EdgePoint(E2, E2B, E2Angle + 90)); + SetHandle(E3AH, EdgePoint(E3, E3A, E3Angle)); + SetHandle(E3BH, EdgePoint(E3, E3B, E3Angle + 90)); + SetHandle(E4AH, EdgePoint(E4, E4A, E4Angle)); + SetHandle(E4BH, EdgePoint(E4, E4B, E4Angle + 90)); + + double rate = FillRate; + string cls = Classification; + Label.Text = $"{rate:F1}% {cls}"; + Label.Foreground = cls == "PASS" ? Brushes.Lime : Brushes.Red; + double labelX = Math.Max(Math.Max(E1.X, E2.X), Math.Max(E3.X, E4.X)) + 15; + double labelY = (E1.Y + E2.Y) / 2; + Canvas.SetLeft(Label, labelX); + Canvas.SetTop(Label, labelY - 10); + Label.Visibility = Visibility.Visible; + } + + /// 获取所有 UI 元素(用于添加/移除) + public UIElement[] AllElements => new UIElement[] + { + PathE1, PathE2, PathE3, PathE4, FullLine, FillLine, Label, + DotE1, DotE2, DotE3, DotE4, + E1AH, E1BH, E2AH, E2BH, E3AH, E3BH, E4AH, E4BH + }; + + private static Point EdgePoint(Point center, double radius, double angleDeg) + { + double rad = angleDeg * Math.PI / 180.0; + return new Point(center.X + radius * Math.Cos(rad), center.Y + radius * Math.Sin(rad)); + } + + private static void SetHandle(Ellipse h, Point pos) + { + if (h == null) return; + Canvas.SetLeft(h, pos.X - h.Width / 2); + Canvas.SetTop(h, pos.Y - h.Height / 2); + } + + public static double Dist(Point a, Point b) + { + double dx = b.X - a.X, dy = b.Y - a.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + private static void UpdateEllipsePath(Path path, Point center, double a, double b, double angleDeg) + { + var eg = new EllipseGeometry(center, a, b); + if (Math.Abs(angleDeg) > 0.01) + eg.Transform = new RotateTransform(angleDeg, center.X, center.Y); + path.Data = eg; + path.Visibility = Visibility.Visible; + } + } +} diff --git a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs index 5de1d9d..f872803 100644 --- a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs +++ b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs @@ -5,6 +5,7 @@ namespace XP.ImageProcessing.RoiControl.Models None, PointDistance, PointToLine, - Angle + Angle, + FillRate } } diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 1722de5..b730afc 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -35,6 +35,13 @@ namespace XplorePlane.ViewModels set => SetProperty(ref _licenseInfo, value); } + private string _statusMessage = "就绪"; + public string StatusMessage + { + get => _statusMessage; + set => SetProperty(ref _statusMessage, value); + } + public ObservableCollection NavigationTree { get; set; } // 导航命令 @@ -461,7 +468,7 @@ namespace XplorePlane.ViewModels private void ExecuteThroughHoleFillRateMeasure() { _logger.Info("通孔填锡率测量功能已触发"); - // TODO: 实现通孔填锡率测量逻辑 + _eventAggregator.GetEvent().Publish(MeasurementToolMode.ThroughHoleFillRate); } #endregion From de21e462e723375d149723cf178aa21fc51b2fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Fri, 24 Apr 2026 15:38:53 +0800 Subject: [PATCH 03/16] =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=EF=BC=9A=E6=B5=8B=E9=87=8F=E4=BF=A1=E6=81=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=88=B0=E4=B8=BB=E7=95=8C=E9=9D=A2=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=8F=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E7=AA=97=E5=8F=A3=E7=8A=B6=E6=80=81=E6=A0=8F=EF=BC=8C?= =?UTF-8?q?MainViewModel=E6=94=B9=E4=B8=BA=E5=8D=95=E4=BE=8B=E6=B3=A8?= =?UTF-8?q?=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - XplorePlane/App.xaml.cs | 2 +- XplorePlane/Views/Main/MainWindow.xaml | 2 +- XplorePlane/Views/Main/ViewportPanelView.xaml | 2 - .../Views/Main/ViewportPanelView.xaml.cs | 37 +++++++++++++------ 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index a13a714..0b8391a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ bld/ lib/ XP.ImageProcessing/ ImageProcessing.sln -ExternalLibraries/ # 排除 Libs 目录中的 DLL 和 PDB 文件(但保留目录结构) XplorePlane/Libs/Hardware/*.dll diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 26cb81b..a66e158 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -334,7 +334,7 @@ namespace XplorePlane // 注册视图和视图模型 containerRegistry.RegisterForNavigation(); - containerRegistry.Register(); + containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); // 注册图像处理服务与视图 diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml index 42597af..3342471 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -571,7 +571,7 @@ FontFamily="Microsoft YaHei UI" FontSize="11" Foreground="White" - Text="就绪" /> + Text="{Binding StatusMessage}" /> - - diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs index 9438a2f..888c67e 100644 --- a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs +++ b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs @@ -15,33 +15,47 @@ namespace XplorePlane.Views { public partial class ViewportPanelView : UserControl { + private MainViewModel _mainVm; + + private void SetStatus(string msg) + { + if (_mainVm == null) + { + try { _mainVm = ContainerLocator.Current?.Resolve(); } catch { } + } + if (_mainVm != null) _mainVm.StatusMessage = msg; + } + public ViewportPanelView() { InitializeComponent(); DataContextChanged += OnDataContextChanged; - // 测量事件 → 更新状态栏 + // 测量事件 → 更新主界面状态栏 RoiCanvas.MeasureCompleted += (s, e) => { - if (e is MeasureCompletedEventArgs args && DataContext is ViewportPanelViewModel vm) + if (e is MeasureCompletedEventArgs args) { string typeLabel = args.MeasureType switch { "PointToLine" => "点线距", "Angle" => "角度", + "FillRate" => "填锡率", _ => "点点距" }; - string valueText = args.MeasureType == "Angle" - ? $"{args.Distance:F2}°" - : $"{args.Distance:F2} px"; - vm.MeasurementResult = valueText; - vm.ImageInfo = $"{typeLabel}: {valueText} | 共 {args.TotalCount} 条测量"; + string valueText = args.MeasureType switch + { + "Angle" => $"{args.Distance:F2}°", + "FillRate" => $"{args.Distance:F1}%", + _ => $"{args.Distance:F2} px" + }; + SetStatus($"{typeLabel}: {valueText} | 共 {args.TotalCount} 条测量"); } }; RoiCanvas.MeasureStatusChanged += (s, e) => { - if (e is MeasureStatusEventArgs args && DataContext is ViewportPanelViewModel vm) - vm.ImageInfo = args.Message; + if (e is MeasureStatusEventArgs args) + SetStatus(args.Message); }; // 十字辅助线:直接订阅 Prism 事件 @@ -61,6 +75,7 @@ namespace XplorePlane.Views MeasurementToolMode.PointDistance => XP.ImageProcessing.RoiControl.Models.MeasureMode.PointDistance, MeasurementToolMode.PointLineDistance => XP.ImageProcessing.RoiControl.Models.MeasureMode.PointToLine, MeasurementToolMode.Angle => XP.ImageProcessing.RoiControl.Models.MeasureMode.Angle, + MeasurementToolMode.ThroughHoleFillRate => XP.ImageProcessing.RoiControl.Models.MeasureMode.FillRate, _ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None }; }, Prism.Events.ThreadOption.UIThread); @@ -91,10 +106,8 @@ namespace XplorePlane.Views { RoiCanvas.ClearMeasurements(); if (DataContext is ViewportPanelViewModel vm) - { vm.ResetMeasurementState(); - vm.ImageInfo = "已清除所有测量"; - } + SetStatus("已清除所有测量"); } private void SaveOriginalImage_Click(object sender, RoutedEventArgs e) From ef2ef5cb83d7845e6049a8d57921856fbe3f0e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Fri, 24 Apr 2026 15:58:59 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E6=B7=BB=E5=8A=A0BGA=E7=A9=BA=E9=9A=99?= =?UTF-8?q?=E6=B5=8B=E9=87=8F=E6=8C=89=E9=92=AE=E5=88=B0=E6=B5=8B=E9=87=8F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- XplorePlane/Events/MeasurementToolEvent.cs | 3 ++- XplorePlane/ViewModels/Main/MainViewModel.cs | 8 ++++++++ XplorePlane/Views/Main/MainWindow.xaml | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/XplorePlane/Events/MeasurementToolEvent.cs b/XplorePlane/Events/MeasurementToolEvent.cs index 7d8b129..279af4a 100644 --- a/XplorePlane/Events/MeasurementToolEvent.cs +++ b/XplorePlane/Events/MeasurementToolEvent.cs @@ -11,7 +11,8 @@ namespace XplorePlane.Events PointDistance, PointLineDistance, Angle, - ThroughHoleFillRate + ThroughHoleFillRate, + BgaVoid } /// diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index b730afc..14f5ecb 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -87,6 +87,7 @@ namespace XplorePlane.ViewModels public DelegateCommand PointLineDistanceMeasureCommand { get; } public DelegateCommand AngleMeasureCommand { get; } public DelegateCommand ThroughHoleFillRateMeasureCommand { get; } + public DelegateCommand BgaVoidMeasureCommand { get; } // 辅助线命令 public DelegateCommand ToggleCrosshairCommand { get; } @@ -173,6 +174,7 @@ namespace XplorePlane.ViewModels PointLineDistanceMeasureCommand = new DelegateCommand(ExecutePointLineDistanceMeasure); AngleMeasureCommand = new DelegateCommand(ExecuteAngleMeasure); ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure); + BgaVoidMeasureCommand = new DelegateCommand(ExecuteBgaVoidMeasure); // 辅助线命令 ToggleCrosshairCommand = new DelegateCommand(() => @@ -471,6 +473,12 @@ namespace XplorePlane.ViewModels _eventAggregator.GetEvent().Publish(MeasurementToolMode.ThroughHoleFillRate); } + private void ExecuteBgaVoidMeasure() + { + _logger.Info("BGA空隙测量功能已触发"); + _eventAggregator.GetEvent().Publish(MeasurementToolMode.BgaVoid); + } + #endregion #region 设置命令实现 diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml index 3342471..f2c3aa2 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -442,6 +442,17 @@ SmallImage="/Assets/Icons/pores.png" Text="通孔填锡率" /> + + + + + _ptlGroups = new(); private readonly System.Collections.Generic.List _angleGroups = new(); private readonly System.Collections.Generic.List _frGroups = new(); + private readonly System.Collections.Generic.List _bgaGroups = new(); // 点点距临时状态 private Ellipse _pendingDot; @@ -363,6 +364,12 @@ namespace XP.ImageProcessing.RoiControl.Controls private Line _angleTempLineA; private Point? _angleTempV, _angleTempA; + // BGA 空隙测量状态 + private Models.BgaVoidGroup _bgaCurrent; // 当前正在编辑的 BGA 组 + private bool _bgaDrawBall; // true=画焊球, false=画气泡 + private Point? _bgaPendingCenter; // 等待第二次点击定半径 + private Ellipse _bgaPendingDot; + // 拖拽状态 private Ellipse _mDraggingDot; private object _mDraggingOwner; @@ -396,6 +403,8 @@ namespace XP.ImageProcessing.RoiControl.Controls if (_angleTempADot != null) { _measureOverlay.Children.Remove(_angleTempADot); _angleTempADot = null; } if (_angleTempLineA != null) { _measureOverlay.Children.Remove(_angleTempLineA); _angleTempLineA = null; } _angleTempV = _angleTempA = null; _angleClickCount = 0; + if (_bgaPendingDot != null) { _measureOverlay.Children.Remove(_bgaPendingDot); _bgaPendingDot = null; } + _bgaPendingCenter = null; } private void EnsureMeasureOverlay() @@ -410,7 +419,7 @@ namespace XP.ImageProcessing.RoiControl.Controls private void RemoveMeasureOverlay() { if (_measureOverlay != null) { mainCanvas.Children.Remove(_measureOverlay); _measureOverlay = null; } - _ppGroups.Clear(); _ptlGroups.Clear(); _angleGroups.Clear(); _frGroups.Clear(); + _ppGroups.Clear(); _ptlGroups.Clear(); _angleGroups.Clear(); _frGroups.Clear(); _bgaGroups.Clear(); _pendingDot = null; _pendingPoint = null; _ptlTempDot1 = _ptlTempDot2 = null; _ptlTempLine = null; _ptlTempL1 = _ptlTempL2 = null; _ptlClickCount = 0; @@ -420,7 +429,7 @@ namespace XP.ImageProcessing.RoiControl.Controls } public void ClearMeasurements() => RemoveMeasureOverlay(); - public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count + _frGroups.Count; + public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count + _frGroups.Count + _bgaGroups.Count; // ── 点击分发 ── @@ -437,6 +446,8 @@ namespace XP.ImageProcessing.RoiControl.Controls HandleAngleClick(pos); else if (CurrentMeasureMode == Models.MeasureMode.FillRate) HandleFillRateClick(pos); + else if (CurrentMeasureMode == Models.MeasureMode.BgaVoid) + HandleBgaVoidClick(pos); } // ── 点点距 ── @@ -634,7 +645,7 @@ namespace XP.ImageProcessing.RoiControl.Controls g.Label = new TextBlock { FontSize = 13, FontWeight = FontWeights.Bold, Cursor = Cursors.Hand }; g.Label.SetValue(ContextMenuService.IsEnabledProperty, false); - g.Label.MouseRightButtonUp += (s, ev) => { ShowTHTLimitEditor(g); ev.Handled = true; }; + g.Label.PreviewMouseRightButtonUp += (s, ev) => { ShowTHTLimitEditor(g); ev.Handled = true; }; g.DotE1 = CreateMDot(Brushes.DodgerBlue); g.DotE2 = CreateMDot(Brushes.Cyan); @@ -674,6 +685,153 @@ namespace XP.ImageProcessing.RoiControl.Controls return p; } + // ── BGA 空隙测量 ── + + private void HandleBgaVoidClick(Point pos) + { + if (_measureOverlay == null) EnsureMeasureOverlay(); + if (_measureOverlay == null) return; + + // 第一次进入:创建新的 BGA 组 + if (_bgaCurrent == null) + { + _bgaCurrent = new Models.BgaVoidGroup(); + var currentGroup = _bgaCurrent; // 局部变量供闭包捕获 + _bgaCurrent.Label = new TextBlock { FontSize = 13, FontWeight = FontWeights.Bold, Cursor = System.Windows.Input.Cursors.Hand, Visibility = Visibility.Collapsed }; + _bgaCurrent.Label.SetValue(ContextMenuService.IsEnabledProperty, false); + _bgaCurrent.Label.PreviewMouseRightButtonUp += (s, ev) => { ShowBgaLimitEditor(currentGroup); ev.Handled = true; }; + _measureOverlay.Children.Add(_bgaCurrent.Label); + _bgaDrawBall = false; + RaiseMeasureStatusChanged("BGA空隙 - 点击画气泡圆心(右键切换为画焊球)"); + } + + if (!_bgaPendingCenter.HasValue) + { + // 第一次点击:圆心 + _bgaPendingCenter = pos; + _bgaPendingDot = CreateMDot(_bgaDrawBall ? Brushes.Lime : Brushes.Orange); + _measureOverlay.Children.Add(_bgaPendingDot); + SetDotPos(_bgaPendingDot, pos); + RaiseMeasureStatusChanged(_bgaDrawBall ? "BGA - 焊球圆心已定,点击边缘定半径" : "BGA - 气泡圆心已定,点击边缘定半径"); + } + else + { + // 第二次点击:半径 + double r = Math.Max(5, Models.FillRateGroup.Dist(pos, _bgaPendingCenter.Value)); + var circle = CreateBgaCircle(_bgaPendingCenter.Value, r, _bgaDrawBall); + + if (_bgaDrawBall) + { + // 如果已有焊球,移除旧的 + if (_bgaCurrent.Ball != null) + { + _measureOverlay.Children.Remove(_bgaCurrent.Ball.Shape); + _measureOverlay.Children.Remove(_bgaCurrent.Ball.CenterDot); + _measureOverlay.Children.Remove(_bgaCurrent.Ball.EdgeDot); + } + _bgaCurrent.Ball = circle; + // 画完焊球 → 完成本组,计算结果 + _bgaGroups.Add(_bgaCurrent); + RenumberAll(); + _bgaCurrent.UpdateLabel(); + _bgaCurrent.Label.Cursor = System.Windows.Input.Cursors.Hand; + RaiseMeasureCompleted(_bgaCurrent.Ball.Center, _bgaCurrent.Ball.Center, _bgaCurrent.VoidRate, MeasureCount, "BgaVoid"); + _bgaCurrent = null; + _bgaDrawBall = false; + CurrentMeasureMode = Models.MeasureMode.None; + } + else + { + _bgaCurrent.Voids.Add(circle); + RaiseMeasureStatusChanged($"BGA - 已画 {_bgaCurrent.Voids.Count} 个气泡,继续画气泡或右键切换画焊球"); + } + + // 清除临时点 + if (_bgaPendingDot != null) { _measureOverlay.Children.Remove(_bgaPendingDot); _bgaPendingDot = null; } + _bgaPendingCenter = null; + } + } + + /// 右键切换气泡/焊球模式(在 BGA 测量模式下) + private void HandleBgaRightClick() + { + if (CurrentMeasureMode != Models.MeasureMode.BgaVoid || _bgaCurrent == null) return; + _bgaDrawBall = !_bgaDrawBall; + RaiseMeasureStatusChanged(_bgaDrawBall ? "BGA - 已切换为画焊球模式" : "BGA - 已切换为画气泡模式"); + } + + private Models.BgaCircle CreateBgaCircle(Point center, double radius, bool isBall) + { + var c = new Models.BgaCircle { Center = center, Radius = radius, IsBall = isBall }; + c.Shape = new Ellipse + { + Stroke = isBall ? Brushes.Lime : Brushes.Orange, + StrokeThickness = isBall ? 2 : 1.5, + Fill = Brushes.Transparent, + IsHitTestVisible = false + }; + c.CenterDot = CreateMDot(isBall ? Brushes.Lime : Brushes.Orange); + c.EdgeDot = CreateMDot(isBall ? Brushes.Lime : Brushes.Orange); + c.EdgeDot.Width = 8; c.EdgeDot.Height = 8; + c.EdgeDot.Cursor = System.Windows.Input.Cursors.SizeAll; + + _measureOverlay.Children.Add(c.Shape); + _measureOverlay.Children.Add(c.CenterDot); + _measureOverlay.Children.Add(c.EdgeDot); + c.UpdateVisuals(); + return c; + } + + // BGA Limit 编辑(和 FillRate 类似) + private void ShowBgaLimitEditor(Models.BgaVoidGroup g) + { + if (_measureOverlay == null || g == null) return; + RemoveTHTEditor(); + + double left = Canvas.GetLeft(g.Label); + double top = Canvas.GetTop(g.Label) + 22; + + _thtEditLabel = new TextBlock + { + Text = "VoidLimit(%):", + FontSize = 11, Foreground = Brushes.White, + Background = new SolidColorBrush(Color.FromArgb(180, 0, 0, 0)), + Padding = new Thickness(3, 1, 3, 1) + }; + Canvas.SetLeft(_thtEditLabel, left); + Canvas.SetTop(_thtEditLabel, top); + _measureOverlay.Children.Add(_thtEditLabel); + + _thtEditBox = new TextBox + { + Text = g.VoidLimit.ToString("F1"), + Width = 60, Height = 22, FontSize = 12, + Background = Brushes.White, BorderBrush = Brushes.Orange, BorderThickness = new Thickness(2), + Padding = new Thickness(2, 0, 2, 0) + }; + Canvas.SetLeft(_thtEditBox, left + 85); + Canvas.SetTop(_thtEditBox, top); + _measureOverlay.Children.Add(_thtEditBox); + _thtEditBox.Focus(); + _thtEditBox.SelectAll(); + + _thtEditBox.KeyDown += (s, ev) => + { + if (ev.Key == System.Windows.Input.Key.Enter) + { + if (double.TryParse(_thtEditBox.Text, out double val)) + { + g.VoidLimit = System.Math.Clamp(val, 0, 100); + g.UpdateLabel(); + RaiseMeasureCompleted(g.Ball?.Center ?? default, g.Ball?.Center ?? default, g.VoidRate, MeasureCount, "BgaVoid"); + } + RemoveTHTEditor(); + } + else if (ev.Key == System.Windows.Input.Key.Escape) RemoveTHTEditor(); + }; + _thtEditBox.LostFocus += (s, ev) => RemoveTHTEditor(); + } + // ── 共用:圆点创建、定位、拖拽、删除 ── private Ellipse CreateMDot(Brush fill) @@ -741,6 +899,22 @@ namespace XP.ImageProcessing.RoiControl.Controls if (g.E4BH == dot) { _mDraggingOwner = g; _mDraggingRole = "E4B"; break; } } } + // 查找 BGA 组 + if (_mDraggingOwner == null) + { + foreach (var g in _bgaGroups) + { + if (g.Ball?.CenterDot == dot) { _mDraggingOwner = g; _mDraggingRole = "BallCenter"; break; } + if (g.Ball?.EdgeDot == dot) { _mDraggingOwner = g; _mDraggingRole = "BallEdge"; break; } + bool found = false; + for (int vi = 0; vi < g.Voids.Count; vi++) + { + if (g.Voids[vi].CenterDot == dot) { _mDraggingOwner = g; _mDraggingRole = $"V{vi}Center"; found = true; break; } + if (g.Voids[vi].EdgeDot == dot) { _mDraggingOwner = g; _mDraggingRole = $"V{vi}Edge"; found = true; break; } + } + if (found) break; + } + } if (_mDraggingOwner != null) { _mDraggingDot = dot; dot.CaptureMouse(); e.Handled = true; } } @@ -791,6 +965,35 @@ namespace XP.ImageProcessing.RoiControl.Controls frg.UpdateVisuals(); RaiseMeasureCompleted(frg.E3, frg.E4, frg.FillRate, MeasureCount, "FillRate"); } + else if (_mDraggingOwner is Models.BgaVoidGroup bgag) + { + if (_mDraggingRole == "BallCenter" && bgag.Ball != null) + { + bgag.Ball.Center = pos; bgag.Ball.UpdateVisuals(); + } + else if (_mDraggingRole == "BallEdge" && bgag.Ball != null) + { + bgag.Ball.Radius = Math.Max(5, Models.FillRateGroup.Dist(pos, bgag.Ball.Center)); + bgag.Ball.UpdateVisuals(); + } + else if (_mDraggingRole.StartsWith("V") && _mDraggingRole.Length > 1) + { + // 解析 V{index}Center 或 V{index}Edge + string rest = _mDraggingRole.Substring(1); + bool isEdge = rest.EndsWith("Edge"); + string idxStr = isEdge ? rest.Replace("Edge", "") : rest.Replace("Center", ""); + if (int.TryParse(idxStr, out int vi) && vi >= 0 && vi < bgag.Voids.Count) + { + if (isEdge) + bgag.Voids[vi].Radius = Math.Max(5, Models.FillRateGroup.Dist(pos, bgag.Voids[vi].Center)); + else + bgag.Voids[vi].Center = pos; + bgag.Voids[vi].UpdateVisuals(); + } + } + bgag.UpdateLabel(); + RaiseMeasureCompleted(bgag.Ball?.Center ?? default, bgag.Ball?.Center ?? default, bgag.VoidRate, MeasureCount, "BgaVoid"); + } e.Handled = true; } @@ -858,6 +1061,27 @@ namespace XP.ImageProcessing.RoiControl.Controls e.Handled = true; return; } } + // BGA 删除 + foreach (var g in _bgaGroups) + { + bool match = g.Ball?.CenterDot == dot || g.Ball?.EdgeDot == dot; + if (!match) + { + foreach (var v in g.Voids) + { + if (v.CenterDot == dot || v.EdgeDot == dot) { match = true; break; } + } + } + if (match) + { + foreach (var el in g.AllElements) + _measureOverlay.Children.Remove(el); + _bgaGroups.Remove(g); + RenumberAll(); + RaiseMeasureStatusChanged($"已删除测量 | 剩余 {MeasureCount} 条"); + e.Handled = true; return; + } + } } // ── 重编号 ── @@ -885,6 +1109,11 @@ namespace XP.ImageProcessing.RoiControl.Controls _frGroups[i].Index = i + 1; _frGroups[i].UpdateVisuals(); } + for (int i = 0; i < _bgaGroups.Count; i++) + { + _bgaGroups[i].Index = i + 1; + _bgaGroups[i].UpdateLabel(); + } } // ── 填锡率阈值编辑 ── @@ -1164,11 +1393,27 @@ namespace XP.ImageProcessing.RoiControl.Controls private void Canvas_MouseRightButtonDown(object sender, MouseButtonEventArgs e) { + // BGA 模式下右键切换气泡/焊球 + if (CurrentMeasureMode == Models.MeasureMode.BgaVoid && _bgaCurrent != null) + { + HandleBgaRightClick(); + e.Handled = true; + return; + } // 右键点击完成多边形 OnRightClick(); // 不设 e.Handled,让 ContextMenu 正常弹出 } + private void Canvas_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e) + { + // BGA 模式下阻止 ContextMenu 弹出 + if (CurrentMeasureMode == Models.MeasureMode.BgaVoid && _bgaCurrent != null) + { + e.Handled = true; + } + } + private void ROI_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // 选择ROI diff --git a/XP.ImageProcessing.RoiControl/Models/BgaVoidGroup.cs b/XP.ImageProcessing.RoiControl/Models/BgaVoidGroup.cs new file mode 100644 index 0000000..c3c777c --- /dev/null +++ b/XP.ImageProcessing.RoiControl/Models/BgaVoidGroup.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace XP.ImageProcessing.RoiControl.Models +{ + /// BGA 空隙测量中的一个圆(气泡或焊球) + public class BgaCircle + { + public Ellipse Shape { get; set; } // 圆形轮廓 + public Ellipse CenterDot { get; set; } // 中心拖拽点 + public Ellipse EdgeDot { get; set; } // 边缘拖拽点(调半径) + public Point Center { get; set; } + public double Radius { get; set; } + public bool IsBall { get; set; } // true=焊球, false=气泡 + + public double Area => Math.PI * Radius * Radius; + + public void UpdateVisuals() + { + double d = Radius * 2; + Shape.Width = d; Shape.Height = d; + Canvas.SetLeft(Shape, Center.X - Radius); + Canvas.SetTop(Shape, Center.Y - Radius); + + Canvas.SetLeft(CenterDot, Center.X - CenterDot.Width / 2); + Canvas.SetTop(CenterDot, Center.Y - CenterDot.Height / 2); + + var edgePt = new Point(Center.X + Radius, Center.Y); + Canvas.SetLeft(EdgeDot, edgePt.X - EdgeDot.Width / 2); + Canvas.SetTop(EdgeDot, edgePt.Y - EdgeDot.Height / 2); + } + } + + /// 一次 BGA 空隙测量组(1个焊球 + N个气泡 + 标签) + public class BgaVoidGroup + { + public BgaCircle Ball { get; set; } + public List Voids { get; } = new(); + public TextBlock Label { get; set; } + public int Index { get; set; } + public double VoidLimit { get; set; } = 25.0; + + public double VoidRate + { + get + { + if (Ball == null || Ball.Area < 1) return 0; + double totalVoid = 0; + foreach (var v in Voids) totalVoid += v.Area; + return Math.Clamp(totalVoid / Ball.Area * 100.0, 0, 100); + } + } + + public string Classification => VoidRate <= VoidLimit ? "PASS" : "FAIL"; + + public void UpdateLabel() + { + if (Label == null || Ball == null) return; + double rate = VoidRate; + string cls = Classification; + Label.Text = (Index > 0 ? $"#{Index} " : "") + + $"Void: {rate:F1}% | Limit: {VoidLimit:F1}% | {cls}"; + Label.Foreground = cls == "PASS" ? Brushes.Lime : Brushes.Red; + + Canvas.SetLeft(Label, Ball.Center.X + Ball.Radius + 10); + Canvas.SetTop(Label, Ball.Center.Y - 10); + Label.Visibility = Visibility.Visible; + } + + /// 获取所有 UI 元素 + public List AllElements + { + get + { + var list = new List(); + if (Ball != null) { list.Add(Ball.Shape); list.Add(Ball.CenterDot); list.Add(Ball.EdgeDot); } + foreach (var v in Voids) { list.Add(v.Shape); list.Add(v.CenterDot); list.Add(v.EdgeDot); } + if (Label != null) list.Add(Label); + return list; + } + } + } +} diff --git a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs index f872803..700cf53 100644 --- a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs +++ b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs @@ -6,6 +6,7 @@ namespace XP.ImageProcessing.RoiControl.Models PointDistance, PointToLine, Angle, - FillRate + FillRate, + BgaVoid } } diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml index f2c3aa2..c3ce257 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -443,7 +443,7 @@ Text="通孔填锡率" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +