30 Commits

Author SHA1 Message Date
ZHANG Zhengxuan 436eaa54fa 已合并 PR 60: 修复算子工具箱及文件夹组织
1、修复编译问题,CNC运行完,实时图像为最后一个节点的输出
2、移除XP单元测试
3、算子工具箱改为Tab窗体
4、流程图 新增执行到此,和执行全部
5、调整Rabbion按钮为CNC页面内置
6、调整算子参数区为滑块控件
7、新增设置窗体,统一数据存储目录
8、增加下拉图像配方列表
2026-05-07 09:38:47 +08:00
zhengxuan.zhang a4e257e8ce 对主界面rabbion按钮的 开关门 绑定到硬件库层真实的操作 2026-05-07 00:12:21 +08:00
zhengxuan.zhang 2124d0c0b7 将XplorePlane项目中所有中文弹窗改为英文弹窗 2026-05-07 00:03:09 +08:00
zhengxuan.zhang 4a4e45e479 打通射线源与相机的断链问题 2026-05-06 23:55:28 +08:00
zhengxuan.zhang d079e9357b 更新硬件集成文档 2026-05-06 23:26:55 +08:00
zhengxuan.zhang d56caf1ab5 双队列的打通 实时按钮的切换 补充测试用例 2026-05-06 23:25:37 +08:00
zhengxuan.zhang bd9b24beb1 探测器双队列的打通与实时按钮的切换 2026-05-06 23:18:28 +08:00
zhengxuan.zhang f9be56b99f Merge remote-tracking branch 'origin/turbo-002-cnc' into turbo-002-cnc 2026-05-06 20:49:44 +08:00
zhengxuan.zhang 03a8532049 修改说明 2026-05-06 20:46:33 +08:00
zhengxuan.zhang e3a1184805 主视口没有可用图像时,回退到 IAppStateService.LatestDetectorFrame 2026-05-06 20:31:07 +08:00
zhengxuan.zhang b740f8d453 修复探测器的订阅与获取 2026-05-06 18:20:52 +08:00
zhengxuan.zhang 5852e11b9f CNC 运行日志、导出字段顺序、以及参考点/保存点同步适配 2026-05-06 17:43:29 +08:00
zhengxuan.zhang 996b0c5796 修复测试用例AfterExecution_IsRunningFalse_AllNodesIdle 2026-05-06 17:27:18 +08:00
zhengxuan.zhang 3a3ea5b5c9 修复乱码 2026-05-06 17:23:51 +08:00
zhengxuan.zhang aeef1feee3 以硬件库层面运动硬件轴定义为准,同步修改appstate, 包括CNC 节点属性面板和 XP导出 2026-05-06 17:11:35 +08:00
zhengxuan.zhang 1ef876db2c 调整硬件appstate 保持与硬件库层面定义一致 2026-05-06 16:48:09 +08:00
zhengxuan.zhang 1b686066c8 Merge branch 'Develop/XP' into turbo-002-cnc
# Conflicts:
#	XplorePlane/Views/Main/MainWindow.xaml
2026-05-06 15:37:58 +08:00
zhengxuan.zhang 7c0f9dab73 下拉检测模块列表 2026-05-06 15:28:29 +08:00
zhengxuan.zhang 3bee2898c5 Plan 用于 CNC 默认保存和加载,Tools 用于流程图配方 xpm,Data 用于执行结果和中间图像,Report 为报告预留目录 2026-05-06 14:56:07 +08:00
zhengxuan.zhang 9a8831c945 新增设置窗体 2026-05-06 14:13:19 +08:00
zhengxuan.zhang db8a37410f 将流程图按钮改为图标 2026-05-06 13:32:21 +08:00
zhengxuan.zhang a483144d29 调整流水线算子参数区 滑块控件的大小 2026-05-06 13:25:18 +08:00
zhengxuan.zhang 2eb3fed4d0 1、当前根节点选择中是 高亮背景和文字不协调,文字改为黑色
2、延时节点执行时,进度条显示功能失效,修复
   3、自动执行,当加载CNC后,点击rabbion中运行,此时没有看到执行中,已执行,等高亮规则
2026-05-06 11:46:49 +08:00
zhengxuan.zhang 48c419d777 调整CNC编辑页面按钮样式 2026-05-06 11:22:21 +08:00
zhengxuan.zhang d4050b4218 调整CNC按钮逻辑改为内置 2026-05-06 11:12:05 +08:00
zhengxuan.zhang f5dceeceb7 对 流程图 新增 4个状态 已启用 已停用 执行到此 未参与本次执行 和右键菜单,执行到此,执行全部 2026-05-06 10:39:19 +08:00
zhengxuan.zhang fd4048a6c0 修复算子工具箱搜索功能 2026-05-06 10:01:27 +08:00
zhengxuan.zhang fdf2419eb6 移除XP单元测试 2026-04-30 14:12:22 +08:00
zhengxuan.zhang 8a6abfb28b 简化算子工具箱调用入口 2026-04-30 14:08:04 +08:00
zhengxuan.zhang fd9784ecb6 #调整页面布局,新增操作 Tab; 图像算子改为Tab页选择; 2026-04-30 13:56:35 +08:00
69 changed files with 4273 additions and 12212 deletions
+1
View File
@@ -63,3 +63,4 @@ ExternalLibraries/Models/
XplorePlane/Tests/
ExternalLibraries/Telerik/
build_out.txt
XplorePlane/data/
@@ -1705,10 +1705,7 @@
"Telerik.UI.for.Wpf.NetCore.Xaml": "2024.1.408"
},
"runtime": {
"XP.Common.dll": {
"assemblyVersion": "1.4.16.1",
"fileVersion": "1.4.16.1"
}
"XP.Common.dll": {}
},
"resources": {
"en-US/XP.Common.resources.dll": {
File diff suppressed because it is too large Load Diff
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
@@ -16,8 +16,8 @@
Background="Transparent"
BorderBrush="#FFD5DFE5"
BorderThickness="1"
Padding="10"
Margin="0,0,0,10">
Padding="8"
Margin="0,0,0,8">
<StackPanel>
<TextBlock x:Name="txtProcessorName"
FontSize="14"
@@ -32,7 +32,7 @@
<!-- 参数列表 -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="pnlParameters" Margin="5" />
<StackPanel x:Name="pnlParameters" Margin="2" />
</ScrollViewer>
</Grid>
</UserControl>
@@ -143,12 +143,15 @@ public partial class ProcessorParameterControl : UserControl
var textBox = new TextBox
{
Text = param.Value.ToString(),
Width = 100,
HorizontalAlignment = HorizontalAlignment.Left
Width = 56,
MinWidth = 56,
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center
};
if (param.MinValue != null && param.MaxValue != null)
{
var rangeGrid = CreateRangeEditorContainer();
var slider = new Slider
{
Minimum = Convert.ToDouble(param.MinValue),
@@ -156,7 +159,8 @@ public partial class ProcessorParameterControl : UserControl
Value = Convert.ToDouble(param.Value),
TickFrequency = 1,
IsSnapToTickEnabled = true,
Margin = new Thickness(0, 0, 0, 5)
MinWidth = 120,
VerticalAlignment = VerticalAlignment.Center
};
slider.ValueChanged += (s, e) =>
@@ -181,7 +185,11 @@ public partial class ProcessorParameterControl : UserControl
}
};
panel.Children.Add(slider);
Grid.SetColumn(slider, 0);
Grid.SetColumn(textBox, 2);
rangeGrid.Children.Add(slider);
rangeGrid.Children.Add(textBox);
panel.Children.Add(rangeGrid);
}
else
{
@@ -193,9 +201,9 @@ public partial class ProcessorParameterControl : UserControl
OnParameterChanged();
}
};
}
panel.Children.Add(textBox);
panel.Children.Add(textBox);
}
return panel;
}
@@ -211,19 +219,23 @@ public partial class ProcessorParameterControl : UserControl
var textBox = new TextBox
{
Text = Convert.ToDouble(param.Value).ToString("F2"),
Width = 100,
HorizontalAlignment = HorizontalAlignment.Left
Width = 56,
MinWidth = 56,
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center
};
if (param.MinValue != null && param.MaxValue != null)
{
var rangeGrid = CreateRangeEditorContainer();
var slider = new Slider
{
Minimum = Convert.ToDouble(param.MinValue),
Maximum = Convert.ToDouble(param.MaxValue),
Value = Convert.ToDouble(param.Value),
TickFrequency = 0.1,
Margin = new Thickness(0, 0, 0, 5)
MinWidth = 120,
VerticalAlignment = VerticalAlignment.Center
};
slider.ValueChanged += (s, e) =>
@@ -248,7 +260,11 @@ public partial class ProcessorParameterControl : UserControl
}
};
panel.Children.Add(slider);
Grid.SetColumn(slider, 0);
Grid.SetColumn(textBox, 2);
rangeGrid.Children.Add(slider);
rangeGrid.Children.Add(textBox);
panel.Children.Add(rangeGrid);
}
else
{
@@ -260,9 +276,9 @@ public partial class ProcessorParameterControl : UserControl
OnParameterChanged();
}
};
}
panel.Children.Add(textBox);
panel.Children.Add(textBox);
}
return panel;
}
@@ -302,8 +318,8 @@ public partial class ProcessorParameterControl : UserControl
var comboBox = new ComboBox
{
Margin = new Thickness(0, 5, 0, 0),
Width = 200,
HorizontalAlignment = HorizontalAlignment.Left
MinWidth = 160,
HorizontalAlignment = HorizontalAlignment.Stretch
};
if (param.Options != null)
@@ -344,8 +360,8 @@ public partial class ProcessorParameterControl : UserControl
{
Text = param.Value?.ToString() ?? "",
Margin = new Thickness(0, 5, 0, 0),
Width = 200,
HorizontalAlignment = HorizontalAlignment.Left
MinWidth = 160,
HorizontalAlignment = HorizontalAlignment.Stretch
};
textBox.TextChanged += (s, e) =>
@@ -374,4 +390,16 @@ public partial class ProcessorParameterControl : UserControl
pnlParameters.Children.Clear();
UpdateNoProcessorText();
}
private static Grid CreateRangeEditorContainer()
{
var grid = new Grid
{
Margin = new Thickness(0, 0, 0, 4)
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(6) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
return grid;
}
}
+22 -18
View File
@@ -20,21 +20,25 @@ namespace XplorePlane.Tests.Models
public void MotionState_Default_AllZeros()
{
var state = MotionState.Default;
_output.WriteLine($"MotionState.Default: XM={state.XM}, YM={state.YM}, ZT={state.ZT}, ZD={state.ZD}, TiltD={state.TiltD}, Dist={state.Dist}");
_output.WriteLine($" Speeds: XM={state.XMSpeed}, YM={state.YMSpeed}, ZT={state.ZTSpeed}, ZD={state.ZDSpeed}, TiltD={state.TiltDSpeed}, Dist={state.DistSpeed}");
_output.WriteLine($"MotionState.Default: StageX={state.StageX}, StageY={state.StageY}, SourceZ={state.SourceZ}, DetectorZ={state.DetectorZ}, DetectorSwing={state.DetectorSwing}, FDD={state.FDD}");
_output.WriteLine($" Speeds: StageX={state.StageXSpeed}, StageY={state.StageYSpeed}, SourceZ={state.SourceZSpeed}, DetectorZ={state.DetectorZSpeed}, DetectorSwing={state.DetectorSwingSpeed}, FDD={state.FDDSpeed}");
Assert.Equal(0, state.XM);
Assert.Equal(0, state.YM);
Assert.Equal(0, state.ZT);
Assert.Equal(0, state.ZD);
Assert.Equal(0, state.TiltD);
Assert.Equal(0, state.Dist);
Assert.Equal(0, state.XMSpeed);
Assert.Equal(0, state.YMSpeed);
Assert.Equal(0, state.ZTSpeed);
Assert.Equal(0, state.ZDSpeed);
Assert.Equal(0, state.TiltDSpeed);
Assert.Equal(0, state.DistSpeed);
Assert.Equal(0, state.StageX);
Assert.Equal(0, state.StageY);
Assert.Equal(0, state.SourceZ);
Assert.Equal(0, state.DetectorZ);
Assert.Equal(0, state.DetectorSwing);
Assert.Equal(0, state.FDD);
Assert.Equal(0, state.StageXSpeed);
Assert.Equal(0, state.StageYSpeed);
Assert.Equal(0, state.SourceZSpeed);
Assert.Equal(0, state.DetectorZSpeed);
Assert.Equal(0, state.DetectorSwingSpeed);
Assert.Equal(0, state.FDDSpeed);
Assert.Equal(0, state.StageRotation);
Assert.Equal(0, state.FixtureRotation);
Assert.Equal(0, state.FOD);
Assert.Equal(0, state.Magnification);
}
[Fact]
@@ -116,15 +120,15 @@ namespace XplorePlane.Tests.Models
public void MotionState_WithExpression_ProducesNewInstance()
{
var original = MotionState.Default;
var modified = original with { XM = 100 };
_output.WriteLine($"Original.XM={original.XM}, Modified.XM={modified.XM}, SameRef={ReferenceEquals(original, modified)}");
var modified = original with { StageX = 100 };
_output.WriteLine($"Original.StageX={original.StageX}, Modified.StageX={modified.StageX}, SameRef={ReferenceEquals(original, modified)}");
// New instance is different from original
Assert.NotSame(original, modified);
Assert.Equal(100, modified.XM);
Assert.Equal(100, modified.StageX);
// Original is unchanged
Assert.Equal(0, original.XM);
Assert.Equal(0, original.StageX);
}
// ── CalibrationMatrix Transform Tests ─────────────────────────
@@ -6,6 +6,7 @@ using System.Windows.Media.Imaging;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Storage;
using XplorePlane.Tests.Helpers;
using XplorePlane.ViewModels;
using Xunit;
@@ -22,6 +23,7 @@ namespace XplorePlane.Tests.Pipeline
private readonly Mock<IPipelineExecutionService> _mockExecSvc;
private readonly Mock<IPipelinePersistenceService> _mockPersistSvc;
private readonly Mock<ILoggerService> _mockLogger;
private readonly Mock<IXpDataPathService> _mockDataPathService;
public PipelineEditorViewModelTests()
{
@@ -29,11 +31,19 @@ namespace XplorePlane.Tests.Pipeline
_mockExecSvc = new Mock<IPipelineExecutionService>();
_mockPersistSvc = new Mock<IPipelinePersistenceService>();
_mockLogger = new Mock<ILoggerService>();
_mockDataPathService = new Mock<IXpDataPathService>();
_mockLogger.Setup(l => l.ForModule<PipelineEditorViewModel>()).Returns(_mockLogger.Object);
_mockDataPathService.SetupGet(s => s.ToolsPath).Returns(Path.GetTempPath());
}
private PipelineEditorViewModel CreateVm() =>
new PipelineEditorViewModel(_mockImageSvc.Object, _mockExecSvc.Object, _mockPersistSvc.Object, new EventAggregator(), _mockLogger.Object);
new PipelineEditorViewModel(
_mockImageSvc.Object,
_mockExecSvc.Object,
_mockPersistSvc.Object,
new EventAggregator(),
_mockLogger.Object,
_mockDataPathService.Object);
// ── 6.1 AddOperatorCommand ────────────────────────────────────
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Moq;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Storage;
using XplorePlane.Tests.Helpers;
using Xunit;
@@ -199,5 +201,21 @@ namespace XplorePlane.Tests.Pipeline
Assert.Equal(2, result.Count);
}
[Fact]
public async Task LoadAllAsync_UsesToolsPath_WhenConstructedWithDataPathService()
{
var mockImageSvc = TestHelpers.CreateMockImageService(new[] { "Blur" });
var mockDataPathSvc = new Mock<IXpDataPathService>();
mockDataPathSvc.SetupGet(s => s.ToolsPath).Returns(_tempDir);
var service = new PipelinePersistenceService(mockImageSvc.Object, mockDataPathSvc.Object);
await service.SaveAsync(BuildModel("P3", "Blur"), Path.Combine(_tempDir, "p3.xpm"));
var result = await service.LoadAllAsync(null);
Assert.Single(result);
Assert.Equal("P3", result[0].Name);
}
}
}
@@ -12,6 +12,7 @@ using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Storage;
using XplorePlane.Tests.Helpers;
using XplorePlane.ViewModels;
@@ -31,8 +32,16 @@ namespace XplorePlane.Tests.Pipeline
var mockExecSvc = new Mock<IPipelineExecutionService>();
var mockPersistSvc = new Mock<IPipelinePersistenceService>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<PipelineEditorViewModel>()).Returns(mockLogger.Object);
return new PipelineEditorViewModel(mockImageSvc.Object, mockExecSvc.Object, mockPersistSvc.Object, new EventAggregator(), mockLogger.Object);
mockDataPathService.SetupGet(s => s.ToolsPath).Returns(Path.GetTempPath());
return new PipelineEditorViewModel(
mockImageSvc.Object,
mockExecSvc.Object,
mockPersistSvc.Object,
new EventAggregator(),
mockLogger.Object,
mockDataPathService.Object);
}
/// <summary>
@@ -1,7 +1,15 @@
using Moq;
using Prism.Events;
using System;
using System.Windows;
using XP.Common.Logging.Interfaces;
using XP.Hardware.Detector.Abstractions.Enums;
using XP.Hardware.Detector.Abstractions.Events;
using XP.Hardware.Detector.Services;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Abstractions.Events;
using XP.Hardware.MotionControl.Services;
using XP.Hardware.RaySource.Services;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
@@ -11,30 +19,82 @@ using Xunit.Abstractions;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// AppStateService 单元测试。
/// 验证默认状态值、Dispose 后行为、null 参数校验、CalibrationMatrix 缺失时的错误处理。
/// AppStateService unit tests.
/// Verifies default values, null guards, dispose behavior, and hardware-driven motion-state sync.
/// </summary>
public class AppStateServiceTests : IDisposable
{
private readonly AppStateService _service;
private readonly Mock<IRaySourceService> _mockRaySource;
private readonly Mock<IMotionSystem> _mockMotionSystem;
private readonly Mock<IMotionControlService> _mockMotionControlService;
private readonly Mock<IDetectorService> _mockDetectorService;
private readonly Mock<ILinearAxis> _mockStageX;
private readonly Mock<ILinearAxis> _mockStageY;
private readonly Mock<ILinearAxis> _mockSourceZ;
private readonly Mock<ILinearAxis> _mockDetectorZ;
private readonly Mock<IRotaryAxis> _mockDetectorSwing;
private readonly Mock<IRotaryAxis> _mockStageRotation;
private readonly Mock<IRotaryAxis> _mockFixtureRotation;
private readonly Mock<ILoggerService> _mockLogger;
private readonly EventAggregator _eventAggregator;
private readonly ITestOutputHelper _output;
public AppStateServiceTests(ITestOutputHelper output)
{
_output = output;
// Ensure WPF Application exists for Dispatcher
if (Application.Current == null)
{
new Application();
}
_mockRaySource = new Mock<IRaySourceService>();
_mockMotionSystem = new Mock<IMotionSystem>();
_mockMotionControlService = new Mock<IMotionControlService>();
_mockDetectorService = new Mock<IDetectorService>();
_mockStageX = CreateLinearAxis(AxisId.StageX, 0);
_mockStageY = CreateLinearAxis(AxisId.StageY, 0);
_mockSourceZ = CreateLinearAxis(AxisId.SourceZ, 0);
_mockDetectorZ = CreateLinearAxis(AxisId.DetectorZ, 0);
_mockDetectorSwing = CreateRotaryAxis(RotaryAxisId.DetectorSwing, 0);
_mockStageRotation = CreateRotaryAxis(RotaryAxisId.StageRotation, 0);
_mockFixtureRotation = CreateRotaryAxis(RotaryAxisId.FixtureRotation, 0);
_mockLogger = new Mock<ILoggerService>();
_eventAggregator = new EventAggregator();
_mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.StageX)).Returns(_mockStageX.Object);
_mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.StageY)).Returns(_mockStageY.Object);
_mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.SourceZ)).Returns(_mockSourceZ.Object);
_mockMotionSystem.Setup(x => x.GetLinearAxis(AxisId.DetectorZ)).Returns(_mockDetectorZ.Object);
_mockMotionSystem.Setup(x => x.GetRotaryAxis(RotaryAxisId.DetectorSwing)).Returns(_mockDetectorSwing.Object);
_mockMotionSystem.Setup(x => x.GetRotaryAxis(RotaryAxisId.StageRotation)).Returns(_mockStageRotation.Object);
_mockMotionSystem.Setup(x => x.GetRotaryAxis(RotaryAxisId.FixtureRotation)).Returns(_mockFixtureRotation.Object);
_mockMotionControlService
.Setup(x => x.GetCurrentGeometry())
.Returns((0d, 0d, 1d));
// DetectorServiceGetInfo 在未初始化时抛出,模拟此行为
_mockDetectorService
.Setup(x => x.GetInfo())
.Throws(new InvalidOperationException("探测器未初始化"));
_mockDetectorService
.SetupGet(x => x.Status)
.Returns(DetectorStatus.Uninitialized);
_mockDetectorService
.SetupGet(x => x.IsConnected)
.Returns(false);
_mockLogger.Setup(l => l.ForModule<AppStateService>()).Returns(_mockLogger.Object);
_service = new AppStateService(_mockRaySource.Object, _mockLogger.Object);
_service = new AppStateService(
_mockRaySource.Object,
_mockMotionSystem.Object,
_mockMotionControlService.Object,
_mockDetectorService.Object,
_eventAggregator,
_mockLogger.Object);
}
public void Dispose()
@@ -42,13 +102,15 @@ namespace XplorePlane.Tests.Services
_service.Dispose();
}
// ── 默认状态值验证 ──
[Fact]
public void DefaultState_MotionState_IsDefault()
public void DefaultState_MotionState_IsHardwareSnapshot()
{
_output.WriteLine($"MotionState == MotionState.Default: {ReferenceEquals(MotionState.Default, _service.MotionState)}");
Assert.Same(MotionState.Default, _service.MotionState);
Assert.Equal(0, _service.MotionState.StageX);
Assert.Equal(0, _service.MotionState.StageY);
Assert.Equal(0, _service.MotionState.SourceZ);
Assert.Equal(0, _service.MotionState.DetectorZ);
Assert.Equal(0, _service.MotionState.DetectorSwing);
Assert.Equal(0, _service.MotionState.FDD);
}
[Fact]
@@ -72,8 +134,6 @@ namespace XplorePlane.Tests.Services
Assert.Null(_service.CalibrationMatrix);
}
// ── null 参数抛出 ArgumentNullException ──
[Fact]
public void UpdateMotionState_NullArgument_ThrowsArgumentNullException()
{
@@ -102,36 +162,117 @@ namespace XplorePlane.Tests.Services
_output.WriteLine($"UpdateSystemState(null) threw: {ex.GetType().Name}, Param={ex.ParamName}");
}
// ── Dispose 后 Update 被忽略 ──
[Fact]
public void Dispose_ThenUpdate_IsIgnored()
{
var originalState = _service.MotionState;
_service.Dispose();
_output.WriteLine("Service disposed, attempting UpdateMotionState...");
// Should not throw, and state should remain unchanged
var newState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
_service.UpdateMotionState(newState);
_output.WriteLine($"State unchanged after dispose: {ReferenceEquals(originalState, _service.MotionState)}");
Assert.Same(originalState, _service.MotionState);
}
// ── CalibrationMatrix 为 null 时 RequestLinkedView 设置错误状态 ──
[Fact]
public void RequestLinkedView_NoCalibrationMatrix_SetsErrorState()
{
// CalibrationMatrix is null by default
Assert.Null(_service.CalibrationMatrix);
_service.RequestLinkedView(100.0, 200.0);
_output.WriteLine($"RequestLinkedView(100, 200) without CalibrationMatrix: HasError={_service.SystemState.HasError}, ErrorMessage='{_service.SystemState.ErrorMessage}'");
Assert.True(_service.SystemState.HasError);
Assert.NotEmpty(_service.SystemState.ErrorMessage);
}
[Fact]
public void GeometryUpdatedEvent_RefreshesMotionStateFromHardware()
{
_mockStageX.SetupGet(x => x.ActualPosition).Returns(12.5);
_mockStageY.SetupGet(x => x.ActualPosition).Returns(34.5);
_mockSourceZ.SetupGet(x => x.ActualPosition).Returns(56.5);
_mockDetectorZ.SetupGet(x => x.ActualPosition).Returns(78.5);
_mockDetectorSwing.SetupGet(x => x.ActualAngle).Returns(9.5);
_eventAggregator.GetEvent<GeometryUpdatedEvent>()
.Publish(new GeometryData(100, 222.2, 2.22));
Assert.Equal(12.5, _service.MotionState.StageX);
Assert.Equal(34.5, _service.MotionState.StageY);
Assert.Equal(56.5, _service.MotionState.SourceZ);
Assert.Equal(78.5, _service.MotionState.DetectorZ);
Assert.Equal(9.5, _service.MotionState.DetectorSwing);
Assert.Equal(222.2, _service.MotionState.FDD);
}
[Fact]
public void StatusChangedEvent_Acquiring_SyncsDetectorState()
{
// 模拟探测器进入采集状态
_mockDetectorService.SetupGet(x => x.Status).Returns(DetectorStatus.Acquiring);
_mockDetectorService.Setup(x => x.GetInfo()).Throws(new InvalidOperationException());
_eventAggregator.GetEvent<StatusChangedEvent>()
.Publish(DetectorStatus.Acquiring);
// 等待后台线程处理(BackgroundThread 订阅)
System.Threading.Thread.Sleep(100);
Assert.True(_service.DetectorState.IsConnected);
Assert.True(_service.DetectorState.IsAcquiring);
}
[Fact]
public void StatusChangedEvent_Uninitialized_SyncsDetectorStateDisconnected()
{
_eventAggregator.GetEvent<StatusChangedEvent>()
.Publish(DetectorStatus.Uninitialized);
System.Threading.Thread.Sleep(100);
Assert.False(_service.DetectorState.IsConnected);
Assert.False(_service.DetectorState.IsAcquiring);
}
[Fact]
public void ImageCapturedEvent_UpdatesLatestDetectorFrame()
{
Assert.Null(_service.LatestDetectorFrame);
var args = new XP.Hardware.Detector.Abstractions.ImageCapturedEventArgs
{
ImageData = new ushort[4],
Width = 2,
Height = 2,
FrameNumber = 1,
CaptureTime = DateTime.UtcNow
};
_eventAggregator.GetEvent<ImageCapturedEvent>().Publish(args);
// 等待后台线程处理
System.Threading.Thread.Sleep(100);
Assert.Same(args, _service.LatestDetectorFrame);
}
private static Mock<ILinearAxis> CreateLinearAxis(AxisId axisId, double position)
{
var axis = new Mock<ILinearAxis>();
axis.SetupGet(x => x.Id).Returns(axisId);
axis.SetupGet(x => x.ActualPosition).Returns(position);
axis.SetupGet(x => x.Status).Returns(AxisStatus.Idle);
return axis;
}
private static Mock<IRotaryAxis> CreateRotaryAxis(RotaryAxisId axisId, double angle)
{
var axis = new Mock<IRotaryAxis>();
axis.SetupGet(x => x.Id).Returns(axisId);
axis.SetupGet(x => x.ActualAngle).Returns(angle);
axis.SetupGet(x => x.Status).Returns(AxisStatus.Idle);
axis.SetupGet(x => x.Enabled).Returns(true);
return axis;
}
}
}
@@ -3,14 +3,22 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XP.Hardware.Detector.Abstractions;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.Cnc;
using XplorePlane.Services;
using XplorePlane.Services.AppState;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.MainViewport;
using Xunit;
namespace XplorePlane.Tests.Services
@@ -170,13 +178,23 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
public class CncExecutionServiceTests
{
private static (CncExecutionService Service, Mock<IInspectionResultStore> Store, Mock<ILoggerService> Logger)
private static (CncExecutionService Service, Mock<IInspectionResultStore> Store, Mock<ILoggerService> Logger, Mock<IMainViewportService> MainViewport, Mock<IAppStateService> AppState)
CreateService()
{
var mockStore = new Mock<IInspectionResultStore>();
var mockLogger = new Mock<ILoggerService>();
var mockMainViewportService = new Mock<IMainViewportService>();
var mockAppStateService = new Mock<IAppStateService>();
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
var mockImageProcessingService = new Mock<IImageProcessingService>();
var mockEventAggregator = new Mock<IEventAggregator>();
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
// Set up GetEvent<DetectorDisconnectedEvent>() so the constructor subscription doesn't throw
mockEventAggregator
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
.Returns(new DetectorDisconnectedEvent());
mockStore.Setup(s => s.BeginRunAsync(
It.IsAny<InspectionRunRecord>(),
It.IsAny<InspectionAssetWriteRequest>()))
@@ -195,8 +213,15 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
It.IsAny<DateTime?>()))
.Returns(Task.CompletedTask);
var service = new CncExecutionService(mockStore.Object, mockLogger.Object);
return (service, mockStore, mockLogger);
var service = new CncExecutionService(
mockStore.Object,
mockLogger.Object,
mockMainViewportService.Object,
mockAppStateService.Object,
mockPipelineExecutionService.Object,
mockImageProcessingService.Object,
mockEventAggregator.Object);
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService);
}
// ── Property 3: 预取消立即返回 ────────────────────────────────────────
@@ -210,7 +235,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(1, 10),
program =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
using var cts = new CancellationTokenSource();
cts.Cancel();
@@ -237,7 +262,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(2, 8),
program =>
{
var (service, _, _) = CreateService();
var (service, _, _, _, _) = CreateService();
var runningReports = new List<Guid>();
// Use SynchronousProgress to avoid async callback timing issues
@@ -294,7 +319,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
gen.ToArbitrary(),
program =>
{
var (service, _, _) = CreateService();
var (service, _, _, _, _) = CreateService();
var runningIds = new List<Guid>();
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
@@ -345,7 +370,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(1, 8),
program =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
InspectionRunRecord capturedRecord = null;
mockStore.Setup(s => s.BeginRunAsync(
@@ -376,7 +401,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(1, 10),
program =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
service.ExecuteAsync(program, null, CancellationToken.None)
.GetAwaiter().GetResult();
@@ -411,7 +436,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(1, 10),
program =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
InspectionRunRecord capturedRecord = null;
mockStore.Setup(s => s.BeginRunAsync(
@@ -439,7 +464,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(1, 8),
program =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
bool? capturedOverallPass = default;
bool callbackInvoked = false;
@@ -472,7 +497,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
// Validates: Requirements 4.4, 4.5
public void CompleteRunAsync_CalledWithNullOverallPass_WhenCancelled()
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
// Use a WaitDelayNode with long delay so cancellation happens during execution
var waitNode = new WaitDelayNode(Guid.NewGuid(), 0, "LongWait", 5000);
@@ -520,7 +545,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramWithInspectionNodesArb(2),
program =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
// Make AppendNodeResultAsync always throw
mockStore.Setup(s => s.AppendNodeResultAsync(
@@ -577,7 +602,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
gen.ToArbitrary(),
waitNode =>
{
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
bool? capturedOverallPass = default;
mockStore.Setup(s => s.CompleteRunAsync(
@@ -620,7 +645,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
CncProgramGenerators.CncProgramArb(1, 8),
program =>
{
var (service, _, _) = CreateService();
var (service, _, _, _, _) = CreateService();
// Build a map of NodeId → CncNodeViewModel
var nodeVms = program.Nodes
@@ -690,7 +715,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
tuple =>
{
var (program, node, expectedPipelineName) = tuple;
var (service, mockStore, _) = CreateService();
var (service, mockStore, _, _, _) = CreateService();
PipelineExecutionSnapshot capturedSnapshot = null;
mockStore.Setup(s => s.AppendNodeResultAsync(
@@ -0,0 +1,101 @@
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services.MainViewport;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// FsCheck property-based tests for DetectorFramePipelineService configuration correction.
/// Property 11 validates that values &lt;= 0 are corrected to 1.
/// </summary>
public class ConfigCorrectionPropertyTests
{
// ── Helpers ──────────────────────────────────────────────────────────
private static DetectorFramePipelineService CreateServiceWithConfig(
int acquireCapacity,
int processCapacity,
int processEveryN)
{
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<DetectorFramePipelineService>()).Returns(mockLogger.Object);
var mockMainViewport = new Mock<IMainViewportService>();
var eventAggregator = new EventAggregator();
return new DetectorFramePipelineService(
eventAggregator,
mockMainViewport.Object,
mockLogger.Object,
acquireCapacity,
processCapacity,
processEveryN);
}
// ── Property 11 ──────────────────────────────────────────────────────
// Feature: live-image-display, Property 11: 配置值下界修正
// Validates: Requirements 6.3, 6.4
//
// For any integer config value <= 0 (ProcessEveryNFrames, AcquireQueueCapacity,
// ProcessQueueCapacity), DetectorFramePipelineService corrects it to 1.
[Property(MaxTest = 100)]
public Property InvalidConfigValues_AreCorrectedToOne()
{
// Generate values <= 0 (including 0 and negative integers)
var nonPositiveGen = Gen.Choose(int.MinValue / 2, 0);
var gen =
from acquireCapacity in nonPositiveGen
from processCapacity in nonPositiveGen
from processEveryN in nonPositiveGen
select (acquireCapacity, processCapacity, processEveryN);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (acquireCapacity, processCapacity, processEveryN) = tuple;
using var service = CreateServiceWithConfig(acquireCapacity, processCapacity, processEveryN);
// All three values must be corrected to 1
bool acquireCorrected = service.AcquireQueueCapacity == 1;
bool processCorrected = service.ProcessQueueCapacity == 1;
bool everyNCorrected = service.ProcessEveryNFrames == 1;
return acquireCorrected && processCorrected && everyNCorrected;
});
}
// Additional property: valid positive values are preserved as-is
[Property(MaxTest = 100)]
public Property ValidConfigValues_ArePreservedAsIs()
{
var positiveGen = Gen.Choose(1, 1000);
var gen =
from acquireCapacity in positiveGen
from processCapacity in positiveGen
from processEveryN in positiveGen
select (acquireCapacity, processCapacity, processEveryN);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (acquireCapacity, processCapacity, processEveryN) = tuple;
using var service = CreateServiceWithConfig(acquireCapacity, processCapacity, processEveryN);
return service.AcquireQueueCapacity == acquireCapacity
&& service.ProcessQueueCapacity == processCapacity
&& service.ProcessEveryNFrames == processEveryN;
});
}
}
}
@@ -0,0 +1,194 @@
using System;
using System.Threading;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XP.Hardware.Detector.Abstractions;
using XP.Hardware.Detector.Abstractions.Events;
using XplorePlane.Services.MainViewport;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// FsCheck property-based tests for DetectorFramePipelineService.
/// Properties 68 validate queue bounds and sampling correctness.
/// </summary>
public class DetectorFramePipelineServicePropertyTests
{
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Creates a DetectorFramePipelineService with the given capacity/sampling config.
/// Uses the internal test constructor to bypass App.config reads.
/// Uses a real EventAggregator so we can publish ImageCapturedEvent to drive the service.
/// </summary>
private static (DetectorFramePipelineService Service, EventAggregator EventAggregator)
CreateService(int acquireCapacity = 16, int processCapacity = 8, int processEveryN = 1)
{
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<DetectorFramePipelineService>()).Returns(mockLogger.Object);
var mockMainViewport = new Mock<IMainViewportService>();
var eventAggregator = new EventAggregator();
var service = new DetectorFramePipelineService(
eventAggregator,
mockMainViewport.Object,
mockLogger.Object,
acquireCapacity,
processCapacity,
processEveryN);
return (service, eventAggregator);
}
/// <summary>
/// Publishes M frames via the EventAggregator and waits for background processing.
/// </summary>
private static void PublishFrames(EventAggregator eventAggregator, int frameCount, int width = 4, int height = 4)
{
for (int i = 0; i < frameCount; i++)
{
var args = new ImageCapturedEventArgs
{
ImageData = new ushort[width * height],
Width = (uint)width,
Height = (uint)height,
FrameNumber = i + 1,
CaptureTime = DateTime.UtcNow
};
eventAggregator.GetEvent<ImageCapturedEvent>().Publish(args);
}
// Wait for background thread processing to complete.
// The subscription runs on BackgroundThread, so we need a brief wait.
Thread.Sleep(300);
}
// ── Property 6 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 6: 采集队列有界不变量
// Validates: Requirements 2.2
[Property(MaxTest = 100)]
public Property AcquireQueueCount_NeverExceedsCapacity()
{
var gen =
from capacity in Gen.Choose(1, 10)
from frameCount in Gen.Choose(1, 100)
select (capacity, frameCount);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (capacity, frameCount) = tuple;
var (service, eventAggregator) = CreateService(acquireCapacity: capacity);
try
{
PublishFrames(eventAggregator, frameCount);
return service.AcquireQueueCount <= service.AcquireQueueCapacity;
}
finally
{
service.Dispose();
}
});
}
// ── Property 7 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 7: 处理队列有界不变量
// Validates: Requirements 2.4
[Property(MaxTest = 100)]
public Property ProcessQueueCount_NeverExceedsCapacity()
{
var gen =
from capacity in Gen.Choose(1, 10)
from frameCount in Gen.Choose(1, 100)
select (capacity, frameCount);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (capacity, frameCount) = tuple;
var (service, eventAggregator) = CreateService(processCapacity: capacity);
try
{
PublishFrames(eventAggregator, frameCount);
return service.ProcessQueueCount <= service.ProcessQueueCapacity;
}
finally
{
service.Dispose();
}
});
}
// ── Property 8 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 8: 隔帧抽样正确性
// Validates: Requirements 2.3
//
// For any N (ProcessEveryNFrames) and M frames, the number of frames entering
// the process queue equals ceil(M / N).
//
// We use a large process queue capacity to avoid overflow dropping frames,
// and count frames via ProcessFrameDequeued events.
[Property(MaxTest = 100)]
public Property ProcessQueueEntries_EqualsCeilMDivN()
{
var gen =
from n in Gen.Choose(1, 10)
from m in Gen.Choose(1, 50)
select (n, m);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (n, m) = tuple;
// Use a large process queue capacity so no frames are dropped due to overflow
var (service, eventAggregator) = CreateService(
acquireCapacity: 200,
processCapacity: 200,
processEveryN: n);
int dequeuedCount = 0;
using var allDequeued = new SemaphoreSlim(0);
int expected = (int)Math.Ceiling((double)m / n);
service.ProcessFrameDequeued += (_, __) =>
{
int count = Interlocked.Increment(ref dequeuedCount);
if (count >= expected)
allDequeued.Release();
};
try
{
PublishFrames(eventAggregator, m);
// Wait up to 5 seconds for all expected frames to be dequeued
bool completed = allDequeued.Wait(TimeSpan.FromSeconds(5));
// If we didn't get the expected count, give a bit more time
if (!completed)
Thread.Sleep(500);
return dequeuedCount == expected;
}
finally
{
service.Dispose();
}
});
}
}
}
@@ -11,6 +11,7 @@ using XP.Common.Database.Interfaces;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.Storage;
using Xunit;
namespace XplorePlane.Tests.Services
@@ -294,6 +295,33 @@ namespace XplorePlane.Tests.Services
Assert.Equal(originalHash, snapshot.PipelineHash);
}
[Fact]
public async Task Constructor_WithDataPathService_WritesIntoDataInspectionResultsDirectory()
{
var mockDataPathService = new Mock<IXpDataPathService>();
var dataRoot = Path.Combine(_tempRoot, "xpdata", "Data");
mockDataPathService.SetupGet(s => s.DataPath).Returns(dataRoot);
var store = new InspectionResultStore(_dbContext, _mockLogger.Object, mockDataPathService.Object);
var run = new InspectionRunRecord
{
ProgramName = "Program-By-Service",
WorkpieceId = "Part-03",
SerialNumber = "SN-DATA"
};
await store.BeginRunAsync(run);
await store.CompleteRunAsync(run.RunId);
var manifestPath = Path.Combine(
dataRoot,
"InspectionResults",
run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar),
"manifest.json");
Assert.True(File.Exists(manifestPath));
}
public void Dispose()
{
_dbContext.Dispose();
@@ -0,0 +1,208 @@
using System;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services.MainViewport;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// FsCheck property-based tests for MainViewportService.
/// Uses the real MainViewportService (no mocking of the service itself).
/// </summary>
public class MainViewportServicePropertyTests
{
// ── Helpers ──────────────────────────────────────────────────────────
private static MainViewportService CreateService()
{
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<MainViewportService>()).Returns(mockLogger.Object);
return new MainViewportService(mockLogger.Object);
}
/// <summary>
/// Creates a frozen BitmapSource suitable for cross-thread use in tests.
/// </summary>
private static BitmapSource CreateFrozenBitmap(int width = 4, int height = 4)
{
var pixels = new byte[width * height * 4];
var bitmap = BitmapSource.Create(
width, height, 96, 96,
PixelFormats.Bgra32, null,
pixels, width * 4);
bitmap.Freeze();
return bitmap;
}
/// <summary>
/// Generator for DetectorFrame with a frozen BitmapSource.
/// </summary>
private static Gen<DetectorFrame> DetectorFrameGen =>
from frameId in Gen.Choose(1, 100000).Select(i => (long)i)
from width in Gen.Choose(1, 64)
from height in Gen.Choose(1, 64)
select new DetectorFrame(
frameId: frameId,
captureTime: DateTime.UtcNow,
width: width,
height: height,
rawPixels: new ushort[width * height],
previewImage: CreateFrozenBitmap(width, height));
// ── Property 1 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 1: 实时模式下帧更新触发显示刷新
// Validates: Requirements 3.2
[Property(MaxTest = 100)]
public Property LiveDetector_RealtimeEnabled_UpdateDetectorFrame_UpdatesDisplayAndRaisesEvent()
{
return Prop.ForAll(
DetectorFrameGen.ToArbitrary(),
frame =>
{
var service = CreateService();
// Ensure we are in LiveDetector mode with realtime enabled (default state)
service.SetSourceMode(MainViewportSourceMode.LiveDetector);
service.SetRealtimeDisplayEnabled(true);
bool stateChangedRaised = false;
service.StateChanged += (_, __) => stateChangedRaised = true;
service.UpdateDetectorFrame(frame);
bool imageMatches = ReferenceEquals(service.CurrentDisplayImage, frame.PreviewImage);
bool eventRaised = stateChangedRaised;
return imageMatches && eventRaised;
});
}
// ── Property 2 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 2: 实时关闭时帧更新不触发显示刷新
// Validates: Requirements 3.3
[Property(MaxTest = 100)]
public Property LiveDetector_RealtimeDisabled_UpdateDetectorFrame_NoEventAndImageUnchanged()
{
return Prop.ForAll(
DetectorFrameGen.ToArbitrary(),
frame =>
{
var service = CreateService();
// Set up: LiveDetector mode, realtime disabled
service.SetSourceMode(MainViewportSourceMode.LiveDetector);
service.SetRealtimeDisplayEnabled(false);
// Capture the display image before the update
var imageBefore = service.CurrentDisplayImage;
bool stateChangedRaised = false;
service.StateChanged += (_, __) => stateChangedRaised = true;
service.UpdateDetectorFrame(frame);
bool noEvent = !stateChangedRaised;
bool imageUnchanged = ReferenceEquals(service.CurrentDisplayImage, imageBefore);
return noEvent && imageUnchanged;
});
}
// ── Property 3 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 3: ManualImage 模式下探测器帧不覆盖显示图像
// Validates: Requirements 3.4
[Property(MaxTest = 100)]
public Property ManualImage_MultipleUpdateDetectorFrame_DisplayAlwaysManualImage()
{
var gen =
from frameCount in Gen.Choose(1, 10)
from frames in Gen.ListOf<DetectorFrame>(DetectorFrameGen, frameCount)
select frames;
return Prop.ForAll(
gen.ToArbitrary(),
frames =>
{
var service = CreateService();
// Set a manual image first
var manualImage = CreateFrozenBitmap(8, 8);
service.SetManualImage(manualImage, "test.png");
// Verify we are in ManualImage mode
if (service.CurrentSourceMode != MainViewportSourceMode.ManualImage)
return false;
// Call UpdateDetectorFrame multiple times
foreach (var frame in (System.Collections.Generic.IEnumerable<DetectorFrame>)frames)
{
service.UpdateDetectorFrame(frame);
}
// CurrentDisplayImage must still be the manual image
return ReferenceEquals(service.CurrentDisplayImage, manualImage);
});
}
// ── Property 4 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 4: CNC 运行时 SetManualImage 被忽略
// Validates: Requirements 3.10
[Property(MaxTest = 100)]
public Property CncRunning_SetManualImage_SourceModeRemainsLiveDetector()
{
return Prop.ForAll(
DetectorFrameGen.ToArbitrary(),
frame =>
{
var service = CreateService();
// Start CNC running
service.SetCncRunning(true);
// Attempt to set a manual image
var manualImage = CreateFrozenBitmap(4, 4);
service.SetManualImage(manualImage, "manual.png");
// CurrentSourceMode must remain LiveDetector
return service.CurrentSourceMode == MainViewportSourceMode.LiveDetector;
});
}
// ── Property 5 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 5: CNC 运行时无法关闭实时刷新
// Validates: Requirements 3.11
[Property(MaxTest = 100)]
public Property CncRunning_SetRealtimeDisplayEnabledFalse_IsRealtimeDisplayEnabledRemainsTrue()
{
// No generator needed — fixed input
return Prop.ForAll(
Gen.Constant(true).ToArbitrary(),
_ =>
{
var service = CreateService();
// Ensure realtime is enabled first
service.SetRealtimeDisplayEnabled(true);
// Start CNC running
service.SetCncRunning(true);
// Attempt to disable realtime display
service.SetRealtimeDisplayEnabled(false);
// IsRealtimeDisplayEnabled must remain true
return service.IsRealtimeDisplayEnabled;
});
}
}
}
@@ -96,7 +96,7 @@ namespace XplorePlane.Tests.Services
var pipeline = new PipelineModel { Name = "TestPipeline" };
var step = _service.RecordCurrentStep(recipe, pipeline);
_output.WriteLine($"RecordCurrentStep: StepIndex={step.StepIndex}, MotionState.XM={step.MotionState.XM}, RaySource.Voltage={step.RaySourceState.Voltage}, Detector.Resolution={step.DetectorState.Resolution}, Pipeline={step.Pipeline.Name}");
_output.WriteLine($"RecordCurrentStep: StepIndex={step.StepIndex}, MotionState.StageX={step.MotionState.StageX}, RaySource.Voltage={step.RaySourceState.Voltage}, Detector.Resolution={step.DetectorState.Resolution}, Pipeline={step.Pipeline.Name}");
Assert.Equal(0, step.StepIndex);
Assert.Same(motionState, step.MotionState);
@@ -1,4 +1,4 @@
// Feature: cnc-run-execution
// Feature: cnc-run-execution
// Properties 1, 2, 12: CncEditorViewModel execution control
using System;
@@ -16,6 +16,8 @@ using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.Storage;
using XplorePlane.Services;
using XplorePlane.ViewModels.Cnc;
using Xunit;
@@ -23,16 +25,20 @@ namespace XplorePlane.Tests.ViewModels
{
public class CncEditorViewModelTests
{
// ── Helpers ──────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────────────
private static CncEditorViewModel CreateVm(
Mock<ICncExecutionService> mockExecSvc = null,
CncProgram initialProgram = null)
CncProgram initialProgram = null,
Mock<IPipelinePersistenceService> mockPipelinePersistenceService = null)
{
var mockCncProgramSvc = new Mock<ICncProgramService>();
var mockAppState = new Mock<IAppStateService>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
mockPipelinePersistenceService ??= new Mock<IPipelinePersistenceService>();
mockLogger.Setup(l => l.ForModule<CncEditorViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.PlanPath).Returns(System.IO.Path.GetTempPath());
mockExecSvc ??= new Mock<ICncExecutionService>();
@@ -47,12 +53,49 @@ namespace XplorePlane.Tests.ViewModels
new ReferencePointNode(Guid.NewGuid(), 0, "参考点_0", 0, 0, 0, 0, 0, 0, false, 0, 0)
}.AsReadOnly()));
mockCncProgramSvc
.Setup(s => s.CreateNode(It.IsAny<CncNodeType>()))
.Returns((CncNodeType nodeType) => nodeType switch
{
CncNodeType.InspectionModule => new InspectionModuleNode(Guid.NewGuid(), 0, "检测模块_0", new PipelineModel()),
CncNodeType.ReferencePoint => new ReferencePointNode(Guid.NewGuid(), 0, "参考点_0", 0, 0, 0, 0, 0, 0, false, 0, 0),
_ => throw new InvalidOperationException($"Unsupported node type in test: {nodeType}")
});
mockCncProgramSvc
.Setup(s => s.InsertNode(It.IsAny<CncProgram>(), It.IsAny<int>(), It.IsAny<CncNode>()))
.Returns((CncProgram program, int afterIndex, CncNode node) =>
{
var nodes = program.Nodes.ToList();
var insertIndex = Math.Clamp(afterIndex + 1, 0, nodes.Count);
nodes.Insert(insertIndex, node with { Index = insertIndex });
for (var i = 0; i < nodes.Count; i++)
{
nodes[i] = nodes[i] with { Index = i };
}
return program with { Nodes = nodes.AsReadOnly(), UpdatedAt = DateTime.UtcNow };
});
mockCncProgramSvc
.Setup(s => s.UpdateNode(It.IsAny<CncProgram>(), It.IsAny<int>(), It.IsAny<CncNode>()))
.Returns((CncProgram program, int index, CncNode updatedNode) =>
{
var nodes = program.Nodes.ToList();
nodes[index] = updatedNode with { Index = index };
return program with { Nodes = nodes.AsReadOnly(), UpdatedAt = DateTime.UtcNow };
});
var vm = new CncEditorViewModel(
mockCncProgramSvc.Object,
mockAppState.Object,
new EventAggregator(),
mockLogger.Object,
mockExecSvc.Object);
mockExecSvc.Object,
mockDataPathService.Object,
mockPipelinePersistenceService.Object);
if (initialProgram != null)
{
@@ -78,7 +121,7 @@ namespace XplorePlane.Tests.ViewModels
return new CncProgram(Guid.NewGuid(), "TestProgram", DateTime.UtcNow, DateTime.UtcNow, nodes);
}
// ── Property 1: 运行/停止按钮状态互斥 ────────────────────────────────
// ── Property 1: 运行/停止按钮状态互斥 ──────────────────────────────────────────
// Feature: cnc-run-execution, Property 1: 运行/停止按钮状态互斥
// Validates: Requirements 1.1, 1.3, 1.4
@@ -125,7 +168,7 @@ namespace XplorePlane.Tests.ViewModels
});
}
// ── Property 2: 执行完成后状态重置 ───────────────────────────────────
// ── Property 2: 执行完成后状态重置 ──────────────────────────────────────────────
// Feature: cnc-run-execution, Property 2: 执行完成后状态重置
// Validates: Requirements 1.7, 6.5
@@ -176,7 +219,7 @@ namespace XplorePlane.Tests.ViewModels
});
}
// ── Property 12: 执行中编辑命令全部禁用 ──────────────────────────────
// ── Property 12: 执行中编辑命令全部禁用 ──────────────────────────────────────────
// Feature: cnc-run-execution, Property 12: 执行中编辑命令全部禁用
// Validates: Requirements 6.7
@@ -228,5 +271,50 @@ namespace XplorePlane.Tests.ViewModels
return allDisabled;
});
}
[Fact]
public async Task InsertInspectionModuleFromPipelineFileAsync_LoadsPipelineAndInsertsNode()
{
var pipelineFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{Guid.NewGuid():N}.xpm");
await System.IO.File.WriteAllTextAsync(pipelineFile, "{}");
try
{
var expectedPipeline = new PipelineModel
{
Name = "BuiltIn/ModuleA"
};
var mockPipelinePersistenceService = new Mock<IPipelinePersistenceService>();
mockPipelinePersistenceService
.Setup(s => s.LoadAsync(pipelineFile))
.ReturnsAsync(expectedPipeline);
var initialProgram = new CncProgram(
Guid.NewGuid(),
"TestProgram",
DateTime.UtcNow,
DateTime.UtcNow,
new List<CncNode>
{
new SavePositionNode(Guid.NewGuid(), 0, "保存位置_0", MotionState.Default)
}.AsReadOnly());
var vm = CreateVm(
initialProgram: initialProgram,
mockPipelinePersistenceService: mockPipelinePersistenceService);
await vm.InsertInspectionModuleFromPipelineFileAsync(pipelineFile);
var insertedNode = Assert.IsType<InspectionModuleNode>(vm.Nodes.Last().Model);
Assert.Same(expectedPipeline, insertedNode.Pipeline);
Assert.Equal("BuiltIn/ModuleA", insertedNode.Pipeline.Name);
}
finally
{
if (System.IO.File.Exists(pipelineFile))
System.IO.File.Delete(pipelineFile);
}
}
}
}
@@ -0,0 +1,216 @@
using System;
using System.Threading;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Moq;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.MainViewport;
using XplorePlane.ViewModels;
namespace XplorePlane.Tests.ViewModels
{
/// <summary>
/// FsCheck property-based tests for ViewportPanelViewModel.
/// Properties 910 validate IsAnimatedSwitchEnabled logic and ImageInfo protection.
/// </summary>
public class ViewportPanelViewModelPropertyTests
{
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Runs an action on a dedicated STA thread with a SynchronizationContext,
/// which satisfies Prism's UIThread requirement for EventAggregator.
/// </summary>
private static T RunOnStaThread<T>(Func<T> action)
{
T result = default;
Exception exception = null;
var thread = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext());
try
{
result = action();
}
catch (Exception ex)
{
exception = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (exception != null)
throw new Exception("STA thread threw an exception", exception);
return result;
}
// ── Property 9 ───────────────────────────────────────────────────────
// Feature: live-image-display, Property 9: AnimatedSwitch 可用状态与连接/CNC 状态一致
// Validates: Requirements 3.13, 7.5, 7.6
//
// For any combination of IsDetectorConnected and IsCncRunning,
// IsAnimatedSwitchEnabled always equals IsDetectorConnected && !IsCncRunning.
//
// The implementation in ViewportPanelViewModel computes:
// IsAnimatedSwitchEnabled = _isDetectorConnected && !_isCncRunning
// We verify this formula holds for all boolean combinations.
[Property(MaxTest = 100)]
public Property IsAnimatedSwitchEnabled_AlwaysEqualsDetectorConnectedAndNotCncRunning()
{
var gen =
from isDetectorConnected in ArbMap.Default.GeneratorFor<bool>()
from isCncRunning in ArbMap.Default.GeneratorFor<bool>()
select (isDetectorConnected, isCncRunning);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (isDetectorConnected, isCncRunning) = tuple;
// Run the entire test on a single STA thread with SynchronizationContext
return RunOnStaThread(() =>
{
var mockViewport = new Mock<IMainViewportService>();
mockViewport.Setup(s => s.CurrentDisplayImage).Returns((ImageSource)null);
mockViewport.Setup(s => s.CurrentDisplayInfo).Returns("等待探测器图像...");
mockViewport.Setup(s => s.IsRealtimeDisplayEnabled).Returns(true);
mockViewport.Setup(s => s.IsCncRunning).Returns(isCncRunning);
var mockAppState = new Mock<IAppStateService>();
mockAppState.SetupAdd(s => s.DetectorStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>());
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<ViewportPanelViewModel>()).Returns(mockLogger.Object);
var eventAggregator = new EventAggregator();
var vm = new ViewportPanelViewModel(
mockViewport.Object,
eventAggregator,
mockAppState.Object,
mockLogger.Object);
// Simulate detector connection state change via DetectorStateChanged event.
var detectorState = new DetectorState(isDetectorConnected, false, 0, "");
var oldState = new DetectorState(!isDetectorConnected, false, 0, "");
var stateChangedArgs = new StateChangedEventArgs<DetectorState>(oldState, detectorState);
mockAppState.Raise(s => s.DetectorStateChanged += null, stateChangedArgs);
// Pump the dispatcher to process BeginInvoke callbacks
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background);
// Simulate CNC running state via StateChanged on the viewport service
mockViewport.Setup(s => s.IsCncRunning).Returns(isCncRunning);
mockViewport.Raise(s => s.StateChanged += null, EventArgs.Empty);
// Pump the dispatcher again
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background);
// The core invariant: IsAnimatedSwitchEnabled == IsDetectorConnected && !IsCncRunning
return vm.IsAnimatedSwitchEnabled == (vm.IsDetectorConnected && !isCncRunning);
});
});
}
// ── Property 10 ──────────────────────────────────────────────────────
// Feature: live-image-display, Property 10: 测量模式下 ImageInfo 不被覆盖
// Validates: Requirements 5.3
//
// For any active measurement mode (CurrentMeasurementMode != None) and any number
// of StateChanged triggers, ImageInfo always retains the measurement hint text
// and is not overwritten by service state.
//
// Implementation note: ViewportPanelViewModel.OnMainViewportStateChanged calls
// UpdateFromState(updateInfo: CurrentMeasurementMode == MeasurementToolMode.None)
// When CurrentMeasurementMode != None, updateInfo is false, so ImageInfo is NOT updated.
// We verify this by directly setting CurrentMeasurementMode and triggering StateChanged.
[Property(MaxTest = 100)]
public Property ActiveMeasurementMode_ImageInfo_NotOverwrittenByStateChanged()
{
// Generate non-None measurement modes
var modeGen = Gen.Elements(
MeasurementToolMode.PointDistance,
MeasurementToolMode.PointLineDistance,
MeasurementToolMode.Angle,
MeasurementToolMode.ThroughHoleFillRate);
var gen =
from mode in modeGen
from triggerCount in Gen.Choose(1, 20)
select (mode, triggerCount);
return Prop.ForAll(
gen.ToArbitrary(),
tuple =>
{
var (mode, triggerCount) = tuple;
// Run the entire test on a single STA thread with SynchronizationContext
return RunOnStaThread(() =>
{
var mockViewport = new Mock<IMainViewportService>();
mockViewport.Setup(s => s.CurrentDisplayImage).Returns((ImageSource)null);
mockViewport.Setup(s => s.CurrentDisplayInfo).Returns("等待探测器图像...");
mockViewport.Setup(s => s.IsRealtimeDisplayEnabled).Returns(true);
mockViewport.Setup(s => s.IsCncRunning).Returns(false);
var mockAppState = new Mock<IAppStateService>();
mockAppState.SetupAdd(s => s.DetectorStateChanged += It.IsAny<EventHandler<StateChangedEventArgs<DetectorState>>>());
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<ViewportPanelViewModel>()).Returns(mockLogger.Object);
var eventAggregator = new EventAggregator();
var vm = new ViewportPanelViewModel(
mockViewport.Object,
eventAggregator,
mockAppState.Object,
mockLogger.Object);
// Directly set the measurement mode on the ViewModel.
// This bypasses the UIThread event dispatch issue and directly
// sets the backing field that controls ImageInfo protection.
vm.CurrentMeasurementMode = mode;
// Set ImageInfo to the measurement hint text (as OnMeasurementToolActivated would)
vm.ImageInfo = vm.MeasurementModeText;
string measurementInfo = vm.ImageInfo;
// Verify measurement mode is active
if (vm.CurrentMeasurementMode == MeasurementToolMode.None)
return false;
// Trigger StateChanged multiple times.
// The handler calls BeginInvoke — we pump the dispatcher to process callbacks.
for (int i = 0; i < triggerCount; i++)
{
mockViewport.Raise(s => s.StateChanged += null, EventArgs.Empty);
// Pump the dispatcher to process BeginInvoke callbacks
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background);
}
// ImageInfo must still be the measurement hint text, not the service info.
// This holds because UpdateFromState is called with updateInfo: false
// when CurrentMeasurementMode != None.
return vm.ImageInfo == measurementInfo;
});
});
}
}
}
+15 -29
View File
@@ -3,19 +3,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36811.4
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XplorePlane", "XplorePlane\XplorePlane.csproj", "{07978DB9-4B88-4F42-9054-73992742BD6A}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XplorePlane", "XplorePlane\XplorePlane.csproj", "{07978DB9-4B88-4F42-9054-73992742BD6A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XplorePlane.Tests", "XplorePlane.Tests\XplorePlane.Tests.csproj", "{6234B622-8DF2-4A8D-AF93-B17774019555}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Camera", "XP.Camera\XP.Camera.csproj", "{82762CDE-48CC-4E28-ABEC-1FC752BACEF4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Camera", "XP.Camera\XP.Camera.csproj", "{82762CDE-48CC-4E28-ABEC-1FC752BACEF4}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.ImageProcessing.Core", "XP.ImageProcessing.Core\XP.ImageProcessing.Core.csproj", "{01EDC1D8-F6BC-2677-AE59-89BA3FC2C74F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.ImageProcessing.Core", "XP.ImageProcessing.Core\XP.ImageProcessing.Core.csproj", "{01EDC1D8-F6BC-2677-AE59-89BA3FC2C74F}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.ImageProcessing.Processors", "XP.ImageProcessing.Processors\XP.ImageProcessing.Processors.csproj", "{2687E12E-3053-E1C6-5268-E4FF547EC212}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.ImageProcessing.Processors", "XP.ImageProcessing.Processors\XP.ImageProcessing.Processors.csproj", "{2687E12E-3053-E1C6-5268-E4FF547EC212}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.ImageProcessing.CfgControl", "XP.ImageProcessing.CfgControl\XP.ImageProcessing.CfgControl.csproj", "{9460CF45-8A25-9770-03AF-4602A2FFF016}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.ImageProcessing.CfgControl", "XP.ImageProcessing.CfgControl\XP.ImageProcessing.CfgControl.csproj", "{9460CF45-8A25-9770-03AF-4602A2FFF016}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.ImageProcessing.RoiControl", "XP.ImageProcessing.RoiControl\XP.ImageProcessing.RoiControl.csproj", "{57061533-EC58-1B1C-3862-9164BC73C806}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.ImageProcessing.RoiControl", "XP.ImageProcessing.RoiControl\XP.ImageProcessing.RoiControl.csproj", "{57061533-EC58-1B1C-3862-9164BC73C806}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExternalLibraries", "ExternalLibraries", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
ProjectSection(SolutionItems) = preProject
@@ -40,31 +38,31 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExternalLibraries", "Extern
ExternalLibraries\version_string.inc = ExternalLibraries\version_string.inc
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Common", "XP.Common\XP.Common.csproj", "{866A7353-C822-114E-48DE-26E8A3E17F9E}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Common", "XP.Common\XP.Common.csproj", "{866A7353-C822-114E-48DE-26E8A3E17F9E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.Detector", "XP.Hardware.Detector\XP.Hardware.Detector.csproj", "{760D5EAC-795A-6666-1BE0-E30B1B2822C3}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Hardware.Detector", "XP.Hardware.Detector\XP.Hardware.Detector.csproj", "{760D5EAC-795A-6666-1BE0-E30B1B2822C3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.MotionControl", "XP.Hardware.MotionControl\XP.Hardware.MotionControl.csproj", "{9391C622-4552-8081-A1E8-B3447E0E7A4F}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Hardware.MotionControl", "XP.Hardware.MotionControl\XP.Hardware.MotionControl.csproj", "{9391C622-4552-8081-A1E8-B3447E0E7A4F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.PLC", "XP.Hardware.PLC\XP.Hardware.PLC.csproj", "{9E9FB4E7-22F9-D475-D9DA-0D647BF6DFD9}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Hardware.PLC", "XP.Hardware.PLC\XP.Hardware.PLC.csproj", "{9E9FB4E7-22F9-D475-D9DA-0D647BF6DFD9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.PLC.Sentry", "XP.Hardware.PLC.Sentry\XP.Hardware.PLC.Sentry.csproj", "{C0635DDF-1BCC-2F86-3BA1-12E0469F114B}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Hardware.PLC.Sentry", "XP.Hardware.PLC.Sentry\XP.Hardware.PLC.Sentry.csproj", "{C0635DDF-1BCC-2F86-3BA1-12E0469F114B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.RaySource", "XP.Hardware.RaySource\XP.Hardware.RaySource.csproj", "{67D180E8-AB8F-FF62-ED46-270803B8F713}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Hardware.RaySource", "XP.Hardware.RaySource\XP.Hardware.RaySource.csproj", "{67D180E8-AB8F-FF62-ED46-270803B8F713}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.RaySource.Comet.Host", "XP.Hardware.RaySource.Comet.Host\XP.Hardware.RaySource.Comet.Host.csproj", "{B8F5E3A1-7C2D-4E9F-A1B3-6D8E4F2C9A01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Hardware.RaySource.Comet.Messages", "XP.Hardware.RaySource.Comet.Messages\XP.Hardware.RaySource.Comet.Messages.csproj", "{6170AF9F-A792-6BDC-4E25-072EA87FAA15}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Hardware.RaySource.Comet.Messages", "XP.Hardware.RaySource.Comet.Messages\XP.Hardware.RaySource.Comet.Messages.csproj", "{6170AF9F-A792-6BDC-4E25-072EA87FAA15}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "XP.Hardware", "XP.Hardware", "{29E2D405-341A-4445-B788-3E77A677C2BA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "XP.ImageProcessing", "XP.ImageProcessing", "{C24535A4-6717-4149-AB81-1EF09A15F90F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Scan", "XP.Scan\XP.Scan.csproj", "{F40C71DC-7639-CD57-6183-2EAA78980EC5}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Scan", "XP.Scan\XP.Scan.csproj", "{F40C71DC-7639-CD57-6183-2EAA78980EC5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "XP.ScanMode", "XP.ScanMode", "{E208A5EA-7E3B-46B4-B045-A703F6274218}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XP.Calibration", "XP.Calibration\XP.Calibration.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XP.Calibration", "XP.Calibration\XP.Calibration.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "XP.Calibration", "XP.Calibration", "{D4E5F6A7-B8C9-0123-4567-89ABCDEF0123}"
EndProject
@@ -90,18 +88,6 @@ Global
{07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x64.Build.0 = Release|Any CPU
{07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x86.ActiveCfg = Release|Any CPU
{07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x86.Build.0 = Release|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x64.ActiveCfg = Debug|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x64.Build.0 = Debug|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x86.ActiveCfg = Debug|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x86.Build.0 = Debug|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Release|Any CPU.Build.0 = Release|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x64.ActiveCfg = Release|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x64.Build.0 = Release|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x86.ActiveCfg = Release|Any CPU
{6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x86.Build.0 = Release|Any CPU
{82762CDE-48CC-4E28-ABEC-1FC752BACEF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{82762CDE-48CC-4E28-ABEC-1FC752BACEF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{82762CDE-48CC-4E28-ABEC-1FC752BACEF4}.Debug|x64.ActiveCfg = Debug|Any CPU
+1
View File
@@ -7,6 +7,7 @@
<appSettings>
<!-- 语言配置 可选值: ZhCN, ZhTW, EnUS| Language Configuration -->
<add key="Language" value="ZhCN" />
<add key="XpData:RootPath" value="D:\XPData" />
<add key="UserManual" value="D:\HMQProject\XplorePlane_CT\Code\XplorePlane\XP.App\bin\Debug\net8.0-windows7.0\UserManual.pdf" />
<!-- Serilog日志配置 -->
+7 -5
View File
@@ -40,6 +40,7 @@ using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Matrix;
using XplorePlane.Services.Measurement;
using XplorePlane.Services.Recipe;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.Views;
@@ -229,8 +230,8 @@ namespace XplorePlane
Log.Fatal(exception, "应用程序发生未处理的异常");
MessageBox.Show(
$"应用程序发生严重错误:\n\n{exception?.Message}\n\n请查看日志文件获取详细信息。",
"严重错误",
$"A fatal error has occurred:\n\n{exception?.Message}\n\nPlease check the log file for details.",
"Fatal Error",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -243,8 +244,8 @@ namespace XplorePlane
Log.Error(e.Exception, "UI 线程发生未处理的异常");
MessageBox.Show(
$"应用程序发生错误:\n\n{e.Exception.Message}\n\n请查看日志文件获取详细信息。",
"错误",
$"An error has occurred:\n\n{e.Exception.Message}\n\nPlease check the log file for details.",
"Error",
MessageBoxButton.OK,
MessageBoxImage.Error);
@@ -369,7 +370,7 @@ namespace XplorePlane
// 注册图像处理服务与视图
containerRegistry.RegisterSingleton<IImageProcessingService, ImageProcessingService>();
containerRegistry.Register<ImageProcessingViewModel>();
containerRegistry.RegisterForNavigation<ImageProcessingPanelView>();
// 注册流水线服务(单例,共享 IImageProcessingService
containerRegistry.RegisterSingleton<IPipelineExecutionService, PipelineExecutionService>();
@@ -377,6 +378,7 @@ namespace XplorePlane
// 注册全局状态服务(单例)
containerRegistry.RegisterSingleton<IAppStateService, AppStateService>();
containerRegistry.RegisterSingleton<IXpDataPathService, XpDataPathService>();
// 注册检测配方服务(单例)
containerRegistry.RegisterSingleton<IRecipeService, RecipeService>();
@@ -1,4 +1,4 @@
# RaySourceOperateView 集成技术路线
# 硬件层及 UI 集成技术路线
## 整体架构
@@ -169,3 +169,357 @@ raySourceService?.Dispose();
```
确保硬件连接在应用退出时正确断开。
### 7.
---
# 硬件层 → AppState → UI 状态同步机制
## 整体结论
| 硬件子系统 | 同步方式 | AppState 订阅 | 状态是否自动同步 |
|-----------|---------|--------------|----------------|
| 运动控制(Motion) | 事件驱动 + 轮询 | ✅ 已订阅 `GeometryUpdatedEvent` / `AxisStatusChangedEvent` | ✅ 自动同步 |
| 射线源(RaySource) | 事件驱动 | ✅ 已订阅 `StatusUpdatedEvent` / `RaySourceStatusChangedEvent` / `VariablesConnectedEvent` | ✅ 自动同步 |
| 探测器(Detector) | 事件驱动 | ✅ 已订阅 `StatusChangedEvent` / `ImageCapturedEvent` | ✅ 自动同步 |
| 相机(Camera | ViewModel 直连 + AppState 同步 | ✅ 连接/采集状态通过 `SyncCameraStateToAppState()` 推送 | ✅ 连接状态自动同步(帧数据不经过 AppState) |
---
## 一、运动控制 — 完整链路
### 1.1 数据流
```
PLC 硬件(B&R
└─ IPlcService.IsConnected(轮询前置检查)
└─ MotionControlService.OnPollingTick() [System.Threading.Timer,周期 = PollingInterval ms]
├─ _motionSystem.UpdateAllStatus() ← 从 PLC 读取所有轴实际位置
├─ GetCurrentGeometry() ← 正算 FOD / FDD / Magnification
├─ 发布 GeometryUpdatedEvent ─┐
└─ 发布 AxisStatusChangedEvent ─┤
AppStateService(构造时订阅两个事件)
├─ OnGeometryUpdated()
└─ OnAxisStatusChanged()
└─ TryRefreshMotionStateFromHardware()
└─ BuildMotionStateSnapshot()
├─ 读取所有轴 ActualPosition / ActualAngle
└─ SetMotionState()
└─ 触发 MotionStateChanged 事件
└─ ViewModel 绑定自动刷新 UI
```
### 1.2 关键实现细节
**轮询启动**`MotionControlService.StartPolling()` 需在应用启动时显式调用,否则轮询不会运行。
**PLC 未连接时的保护**
```csharp
// MotionControlService.OnPollingTick()
if (!_plcService.IsConnected) return; // 直接跳过,不报错
```
**连续错误降频**
```csharp
if (_pollErrorCount > 3)
{
if (++_pollErrorCount % 50 != 0) return; // 每 50 次才尝试一次,防止日志刷屏
}
```
**AppStateService 初始化时的首次刷新**
```csharp
// AppStateService 构造函数末尾
private void SubscribeToExistingServices()
{
if (TryRefreshMotionStateFromHardware("initialization"))
_logger.Info("AppStateService subscribed to motion hardware state");
else
_logger.Warn("AppStateService could not initialize motion state from hardware");
}
```
PLC 未连接时 warn 但不崩溃,等待后续轮询事件触发再同步。
**`UpdateMotionState()` 的特殊行为**
```csharp
public void UpdateMotionState(MotionState newState)
{
// 优先从硬件层拉取最新快照,忽略外部传入值
if (TryRefreshMotionStateFromHardware("UpdateMotionState"))
return;
// 硬件不可用时才使用传入值(降级路径)
SetMotionState(newState);
}
```
硬件连接后,外部调用 `UpdateMotionState()` 实际上会被硬件快照覆盖,硬件层始终是 `MotionState` 的唯一真实来源。
### 1.3 事件清单
| 事件 | 发布方 | 订阅方 | 触发时机 |
|------|--------|--------|---------|
| `GeometryUpdatedEvent` | `MotionControlService` | `AppStateService``MotionControlViewModel``AxisControlViewModel` | 每次轮询 tick |
| `AxisStatusChangedEvent` | `MotionControlService` | `AppStateService``MotionControlViewModel``AxisControlViewModel` | 轴状态发生变化时 |
| `MotionErrorEvent` | `MotionControlService` | — | 轴状态变为 Error / Alarm |
| `DoorStatusChangedEvent` | `MotionControlService` | `MotionControlViewModel` | 安全门状态变化 |
| `DoorInterlockChangedEvent` | `MotionControlService` | — | 联锁状态变化 |
| `GeometryApplyRequestEvent` | DebugWindow | `MotionControlViewModel` | 调试窗口发起几何反算请求 |
---
## 二、射线源 — ✅ 已打通
### 2.1 当前状态
`AppStateService` 已在构造时订阅 `StatusUpdatedEvent``RaySourceStatusChangedEvent``VariablesConnectedEvent``RaySourceState` 自动同步。
```
XP.Hardware.RaySource 发布的事件(AppState 层订阅情况):
├─ StatusUpdatedEvent ← ✅ 已订阅(主路径),映射为 RaySourceState
├─ RaySourceStatusChangedEvent ← ✅ 已订阅(补充路径),快速同步 IsOn 状态
├─ VariablesConnectedEvent ← ✅ 已订阅,断开时重置 RaySourceState 为默认值
└─ OperationResultEvent ← 未订阅(操作结果由 ViewModel 层直接处理)
```
### 2.2 数据流
```
射线源硬件(B&R PVI
└─ IRaySourceServiceXP.Hardware.RaySource
├─ 发布 StatusUpdatedEventSystemStatusData500ms 轮询)
│ └─ AppStateService.OnRaySourceStatusUpdated() [BackgroundThread]
│ ├─ IsOn = data.IsXRayOn
│ ├─ Voltage = data.ActualVoltage (kV)
│ ├─ Power = ActualVoltage × ActualCurrent / 1000 (W)
│ └─ UpdateRaySourceState() → 触发 RaySourceStateChanged → ViewModel 刷新 UI
├─ 发布 RaySourceStatusChangedEventRaySourceStatus 枚举)
│ └─ AppStateService.OnRaySourceStatusChanged() [BackgroundThread]
│ └─ 仅在 IsOn 变化时更新,保留当前 Voltage/Power 值
└─ 发布 VariablesConnectedEventbool
└─ AppStateService.OnRaySourceVariablesConnected() [BackgroundThread]
└─ 断开时:UpdateRaySourceState(RaySourceState.Default)
```
### 2.3 关键实现细节
**双路径设计**`StatusUpdatedEvent` 是主路径,携带完整的电压、电流、开关状态;`RaySourceStatusChangedEvent` 是补充路径,仅在 `StatusUpdatedEvent` 尚未到达时快速同步 `IsOn` 字段,避免覆盖精确数据:
```csharp
// OnRaySourceStatusChanged — 仅当 IsOn 状态发生变化时才更新
if (current.IsOn == isOn) return;
UpdateRaySourceState(new RaySourceState(IsOn: isOn, Voltage: current.Voltage, Power: current.Power));
```
**功率计算**`Power(W) = ActualVoltage(kV) × ActualCurrent(μA) / 1000`
**断连重置**PVI 变量断开时将 `RaySourceState` 重置为 `Default(false, 0, 0)`,防止 UI 显示过期数据。
**Dispose 时取消订阅**:三个 token 均在 `Dispose()` 中取消。
---
## 三、探测器 — ✅ 已打通
### 3.1 当前状态
`AppStateService` 已在构造时订阅 `StatusChangedEvent``ImageCapturedEvent`,探测器状态和最新帧均自动同步。
```
XP.Hardware.Detector 发布的事件(AppState 层订阅情况):
├─ StatusChangedEvent ← ✅ 已订阅,映射为 DetectorState
├─ ImageCapturedEvent ← ✅ 已订阅,缓存为 LatestDetectorFramevolatile
├─ CorrectionCompletedEvent ← 未订阅(无需同步到 AppState)
└─ ErrorOccurredEvent ← 未订阅(无需同步到 AppState)
```
### 3.2 数据流
```
探测器硬件
└─ IDetectorServiceXP.Hardware.Detector
├─ 发布 StatusChangedEventDetectorStatus 枚举)
│ └─ AppStateService.OnDetectorStatusChanged() [BackgroundThread]
│ ├─ 映射 DetectorStatus → DetectorState(IsConnected, IsAcquiring, ...)
│ ├─ UpdateDetectorState()
│ │ └─ 触发 DetectorStateChanged 事件 → ViewModel 刷新 UI
│ └─ 若从已连接变为断开:发布 DetectorDisconnectedEvent
│ ├─ CncExecutionService 订阅 → 取消当前 CNC 执行
│ └─ ViewportPanelViewModel 订阅 → 弹出断连警告对话框
└─ 发布 ImageCapturedEventImageCapturedEventArgs
└─ AppStateService.OnDetectorImageCaptured() [BackgroundThread]
└─ volatile 写 _latestDetectorFrame
└─ 上层通过 LatestDetectorFrame 属性按需读取(CNC 执行、图像处理等)
```
### 3.3 关键实现细节
**订阅均在后台线程**,避免阻塞采集链路:
```csharp
_detectorStatusChangedToken = _eventAggregator
.GetEvent<StatusChangedEvent>()
.Subscribe(OnDetectorStatusChanged, ThreadOption.BackgroundThread);
_detectorImageCapturedToken = _eventAggregator
.GetEvent<ImageCapturedEvent>()
.Subscribe(OnDetectorImageCaptured, ThreadOption.BackgroundThread);
```
**断连检测**`OnDetectorStatusChanged` 在更新状态前记录 `wasConnected`,状态更新后若检测到从已连接变为断开,则发布 `DetectorDisconnectedEvent`
```csharp
bool wasConnected = _detectorState?.IsConnected ?? false;
// ... 更新状态 ...
if (wasConnected && !isConnected)
{
_eventAggregator.GetEvent<DetectorDisconnectedEvent>().Publish();
_logger.Warn("探测器已断连,发布 DetectorDisconnectedEvent");
}
```
**最新帧缓存**`LatestDetectorFrame``volatile` 字段,任意线程可安全读取,无需加锁:
```csharp
private volatile ImageCapturedEventArgs _latestDetectorFrame;
public ImageCapturedEventArgs LatestDetectorFrame => _latestDetectorFrame;
```
**Dispose 时取消订阅**
```csharp
_eventAggregator.GetEvent<StatusChangedEvent>().Unsubscribe(_detectorStatusChangedToken);
_eventAggregator.GetEvent<ImageCapturedEvent>().Unsubscribe(_detectorImageCapturedToken);
```
### 3.4 DetectorDisconnectedEvent 下游链路
`DetectorDisconnectedEvent` 是无载荷 Prism 事件,定义于 `XplorePlane/Events/DetectorDisconnectedEvent.cs`
| 订阅方 | 线程选项 | 行为 |
|--------|---------|------|
| `CncExecutionService` | `BackgroundThread` | 取消 `_executionCts`,中止当前 CNC 执行 |
| `ViewportPanelViewModel` | `UIThread` | 若 CNC 正在运行,弹出 `MessageBox` 警告 |
---
## 四、相机 — ✅ 已打通(连接状态同步)
### 4.1 当前状态
相机图像流数据量大、帧率高,不适合经过 AppState 的不可变 record 替换机制,因此相机的图像帧仍由 `NavigationPropertyPanelViewModel` 直接持有并渲染。但**连接状态和采集状态**现已通过 `UpdateCameraState()` 同步到 `AppStateService`,供其他模块查询。
```
ICameraController 事件(NavigationPropertyPanelViewModel 直接订阅):
├─ ImageGrabbed ← 直接渲染到 CameraImageSource(不经过 AppState,避免高频刷新)
├─ GrabError ← 更新 CameraStatusText
└─ ConnectionLost ← 更新 IsCameraConnected + 同步 CameraState ✅
```
### 4.2 数据流
```
ICameraControllerXP.Camera
└─ NavigationPropertyPanelViewModel(直接持有 _camera
├─ ConnectCamera() / OnCameraReady()
│ └─ IsCameraConnected = true → SyncCameraStateToAppState()
│ └─ AppStateService.UpdateCameraState(IsConnected=true, IsStreaming=false, ...)
├─ DisconnectCamera()
│ └─ IsCameraConnected = false → SyncCameraStateToAppState()
│ └─ AppStateService.UpdateCameraState(IsConnected=false, IsStreaming=false, ...)
├─ StartGrab() / StopGrab()
│ └─ IsCameraGrabbing = true/false → SyncCameraStateToAppState()
│ └─ AppStateService.UpdateCameraState(IsStreaming=true/false, ...)
└─ OnCameraConnectionLost()
└─ IsCameraConnected = false → SyncCameraStateToAppState()
└─ AppStateService.UpdateCameraState(IsConnected=false, IsStreaming=false, ...)
```
### 4.3 关键实现细节
`SyncCameraStateToAppState()` 是统一的同步入口,在所有状态变更节点调用:
```csharp
private void SyncCameraStateToAppState()
{
_appStateService.UpdateCameraState(new CameraState(
IsConnected: IsCameraConnected,
IsStreaming: IsCameraGrabbing,
CurrentFrame: null, // 帧数据不经过 AppState,避免高频触发 UI 刷新
Width: IsCameraConnected ? ImageWidth : 0,
Height: IsCameraConnected ? ImageHeight : 0,
FrameRate: 0));
}
```
**设计决策**`CurrentFrame` 始终为 null,帧数据仍由 ViewModel 直接持有。`CameraState` 只承载连接/采集状态和图像尺寸,供其他模块(如 CNC 执行、状态栏)查询相机是否可用。
**DI 注册**`NavigationPropertyPanelViewModel` 已通过 `RegisterSingleton<NavigationPropertyPanelViewModel>()` 注册,DryIoc 自动解析新增的 `IAppStateService` 构造函数参数,无需修改注册代码。
---
## 五、状态同步全景图
```
┌─────────────────────────────────────────────────────────────────────┐
│ 硬件层 │
│ │
│ PLC ──→ MotionControlService ──→ GeometryUpdatedEvent ─────┐ │
│ └──→ AxisStatusChangedEvent ────┤ │
│ │ │
│ RaySource ──→ IRaySourceService ──→ StatusUpdatedEvent ────┤ │
│ └──→ RaySourceStatusChangedEvent ─┤│
│ └──→ VariablesConnectedEvent ────┤│
│ │ │
│ Detector ──→ IDetectorService ──→ StatusChangedEvent ────┤ │
│ └──→ ImageCapturedEvent ────┤ │
│ │ │
│ Camera ──→ ICameraController ──→ ConnectionLost / Grab ────┘ │
│ (via SyncCameraStateToAppState) │
└─────────────────────────────────────────────────────────────────────┘
│ 全部已接通 ✅
┌─────────────────────────────────────────────────────────────────────┐
│ AppStateService │
│ │
│ MotionState ← 自动同步(轮询 + 事件驱动) ✅ │
│ RaySourceState ← 自动同步(StatusUpdatedEvent 等) ✅ │
│ DetectorState ← 自动同步(StatusChangedEvent ✅ │
│ LatestDetectorFrame ← 自动缓存(ImageCapturedEvent ✅ │
│ CameraState ← 连接/采集状态同步(帧数据不经过) ✅ │
└─────────────────────────────────────────────────────────────────────┘
↓ PropertyChanged / StateChangedEvent
┌─────────────────────────────────────────────────────────────────────┐
│ ViewModel 层 │
│ │
│ MotionControlViewModel ← 同时订阅 GeometryUpdatedEvent(双路) │
│ CncNodeViewModel ← 通过 IAppStateService 读取快照 │
│ CncEditorViewModel ← 通过 IAppStateService 读取快照 │
│ ViewportPanelViewModel ← 订阅 DetectorStateChanged │
│ 订阅 DetectorDisconnectedEvent │
│ CncExecutionService ← 订阅 DetectorDisconnectedEvent │
│ NavigationPropertyPanelViewModel ← 直连 ICameraController │
│ + 推送 CameraState 到 AppState │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 六、待办事项
| 优先级 | 项目 | 涉及文件 | 状态 |
|--------|------|---------|------|
| 已完成 | 射线源事件订阅,打通 `RaySourceState` 自动同步 | `AppStateService.cs` | ✅ 已完成 |
| 已完成 | 探测器事件订阅,打通 `DetectorState` 自动同步 + `LatestDetectorFrame` 缓存 | `AppStateService.cs` | ✅ 已完成 |
| 已完成 | 探测器断连事件(`DetectorDisconnectedEvent`)下游链路 | `CncExecutionService.cs``ViewportPanelViewModel.cs` | ✅ 已完成 |
| 已完成 | 手动模式下实时按钮切换回 `LiveDetector` 模式 | `MainViewportService.cs` | ✅ 已完成 |
| 中 | 确认 `StartPolling()` 在应用启动流程中被调用 | `App.xaml.cs` / `AppBootstrapper` | ✅ 已确认 |
| 低 | 评估是否将相机连接状态纳入 `CameraState` 统一管理 | `NavigationPropertyPanelViewModel.cs``AppStateService.cs` | ✅ 已完成 |
@@ -0,0 +1,12 @@
using Prism.Events;
namespace XplorePlane.Events
{
/// <summary>
/// 探测器断连事件。
/// 当探测器连接状态从已连接变为断开时,由 AppStateService 发布。
/// </summary>
public sealed class DetectorDisconnectedEvent : PubSubEvent
{
}
}
+49 -29
View File
@@ -40,69 +40,89 @@ namespace XplorePlane.Models
Guid Id,
int Index,
CncNodeType NodeType,
string Name
);
string Name);
/// <summary>参考点节点 | Reference point node</summary>
public record ReferencePointNode(
Guid Id, int Index, string Name,
double XM, double YM, double ZT, double ZD, double TiltD, double Dist,
bool IsRayOn, double Voltage, double Current
) : CncNode(Id, Index, CncNodeType.ReferencePoint, Name);
Guid Id,
int Index,
string Name,
double StageX,
double StageY,
double SourceZ,
double DetectorZ,
double DetectorSwing,
double FDD,
bool IsRayOn,
double Voltage,
double Current,
double StageRotation = 0,
double FixtureRotation = 0,
double FOD = 0,
double Magnification = 0) : CncNode(Id, Index, CncNodeType.ReferencePoint, Name);
/// <summary>保存节点(含图像)| Save node with image</summary>
public record SaveNodeWithImageNode(
Guid Id, int Index, string Name,
Guid Id,
int Index,
string Name,
MotionState MotionState,
RaySourceState RaySourceState,
DetectorState DetectorState,
string ImageFileName
) : CncNode(Id, Index, CncNodeType.SaveNodeWithImage, Name);
string ImageFileName) : CncNode(Id, Index, CncNodeType.SaveNodeWithImage, Name);
/// <summary>保存节点(不含图像)| Save node without image</summary>
public record SaveNodeNode(
Guid Id, int Index, string Name,
Guid Id,
int Index,
string Name,
MotionState MotionState,
RaySourceState RaySourceState,
DetectorState DetectorState
) : CncNode(Id, Index, CncNodeType.SaveNode, Name);
DetectorState DetectorState) : CncNode(Id, Index, CncNodeType.SaveNode, Name);
/// <summary>保存位置节点 | Save position node</summary>
public record SavePositionNode(
Guid Id, int Index, string Name,
MotionState MotionState
) : CncNode(Id, Index, CncNodeType.SavePosition, Name);
Guid Id,
int Index,
string Name,
MotionState MotionState) : CncNode(Id, Index, CncNodeType.SavePosition, Name);
/// <summary>检测模块节点 | Inspection module node</summary>
public record InspectionModuleNode(
Guid Id, int Index, string Name,
PipelineModel Pipeline
) : CncNode(Id, Index, CncNodeType.InspectionModule, Name);
Guid Id,
int Index,
string Name,
PipelineModel Pipeline) : CncNode(Id, Index, CncNodeType.InspectionModule, Name);
/// <summary>检测标记节点 | Inspection marker node</summary>
public record InspectionMarkerNode(
Guid Id, int Index, string Name,
Guid Id,
int Index,
string Name,
string MarkerType,
double MarkerX, double MarkerY
) : CncNode(Id, Index, CncNodeType.InspectionMarker, Name);
double MarkerX,
double MarkerY) : CncNode(Id, Index, CncNodeType.InspectionMarker, Name);
/// <summary>停顿对话框节点 | Pause dialog node</summary>
public record PauseDialogNode(
Guid Id, int Index, string Name,
Guid Id,
int Index,
string Name,
string DialogTitle,
string DialogMessage
) : CncNode(Id, Index, CncNodeType.PauseDialog, Name);
string DialogMessage) : CncNode(Id, Index, CncNodeType.PauseDialog, Name);
/// <summary>等待延时节点 | Wait delay node</summary>
public record WaitDelayNode(
Guid Id, int Index, string Name,
int DelayMilliseconds
) : CncNode(Id, Index, CncNodeType.WaitDelay, Name);
Guid Id,
int Index,
string Name,
int DelayMilliseconds) : CncNode(Id, Index, CncNodeType.WaitDelay, Name);
/// <summary>完成程序节点 | Complete program node</summary>
public record CompleteProgramNode(
Guid Id, int Index, string Name
) : CncNode(Id, Index, CncNodeType.CompleteProgram, Name);
Guid Id,
int Index,
string Name) : CncNode(Id, Index, CncNodeType.CompleteProgram, Name);
// ── CNC 程序 | CNC Program ────────────────────────────────────────
+50 -50
View File
@@ -2,8 +2,6 @@ using System;
namespace XplorePlane.Models
{
// ── Enumerations ──────────────────────────────────────────────────
/// <summary>系统操作模式</summary>
public enum OperationMode
{
@@ -23,82 +21,85 @@ namespace XplorePlane.Models
Error // 出错
}
// ── State Records ─────────────────────────────────────────────────
// State Records
/// <summary>运动控制状态(不可变)</summary>
/// <summary>
/// 运动控制状态(不可变)。
/// 统一的运动与几何快照,与运动硬件模型对齐。
/// </summary>
public record MotionState(
double XM, // X 轴位置 (μm)
double YM, // Y 轴位置 (μm)
double ZT, // Z 上轴位置 (μm)
double ZD, // Z 下轴位置 (μm)
double TiltD, // 倾斜角度 (m°)
double Dist, // 距离 (μm)
double XMSpeed, // X 轴速度 (μm/s)
double YMSpeed, // Y 轴速度 (μm/s)
double ZTSpeed, // Z 上轴速度 (μm/s)
double ZDSpeed, // Z 下轴速度 (μm/s)
double TiltDSpeed, // 倾斜速度 (m°/s)
double DistSpeed // 距离速度 (μm/s)
)
double StageX, // X 轴位置μm
double StageY, // Y 轴位置μm
double SourceZ, // Z 上轴位置μm
double DetectorZ, // Z 下轴位置μm
double DetectorSwing, // 探测器摆角(°)
double FDD, // 焦点-探测器距离(μm
double StageXSpeed, // X 轴速度μm/s
double StageYSpeed, // Y 轴速度μm/s
double SourceZSpeed, // Z 上轴速度μm/s
double DetectorZSpeed, // Z 下轴速度μm/s
double DetectorSwingSpeed, // 探测器摆角速度(°/s
double FDDSpeed, // 焦点-探测器距离速度μm/s
double StageRotation = 0, // 载台旋转角度(°)
double FixtureRotation = 0, // 夹具旋转角度(°)
double FOD = 0, // 焦点-物体距离(μm
double Magnification = 0, // 放大倍率
double StageRotationSpeed = 0, // 载台旋转速度(°/s
double FixtureRotationSpeed = 0) // 夹具旋转速度(°/s
{
public static readonly MotionState Default = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
/// <summary>射线源状态(不可变)</summary>
public record RaySourceState(
bool IsOn, // 开关状态
double Voltage, // 电压 (kV)
double Power // 功率 (W)
)
bool IsOn, // 是否开启
double Voltage, // 电压kV
double Power) // 功率W
{
public static readonly RaySourceState Default = new(false, 0, 0);
}
/// <summary>探测器状态(不可变)</summary>
public record DetectorState(
bool IsConnected, // 连接状态
bool IsAcquiring, // 是否正在采集
double FrameRate, // 当前帧率 (fps)
string Resolution // 分辨率描述,如 "2048x2048"
)
bool IsConnected, // 是否已连接
bool IsAcquiring, // 是否正在采集
double FrameRate, // 帧率(fps
string Resolution) // 分辨率描述
{
public static readonly DetectorState Default = new(false, false, 0, string.Empty);
}
/// <summary>系统状态(不可变)</summary>
/// <summary>系统整体状态(不可变)</summary>
public record SystemState(
OperationMode OperationMode, // 当前操作模式
bool HasError, // 是否存在系统错误
string ErrorMessage // 错误描述
)
OperationMode OperationMode, // 当前操作模式
bool HasError, // 是否存在错误
string ErrorMessage) // 错误信息
{
public static readonly SystemState Default = new(OperationMode.Idle, false, string.Empty);
}
/// <summary>摄像头视频流状态(不可变)</summary>
/// <summary>相机状态(不可变)</summary>
public record CameraState(
bool IsConnected, // 连接状态
bool IsStreaming, // 是否正在推流
object CurrentFrame, // 当前帧数据引用(BitmapSource 或 byte[]Frozen
int Width, // 分辨率宽
int Height, // 分辨率高
double FrameRate // 帧率 (fps)
)
bool IsConnected, // 是否已连接
bool IsStreaming, // 是否正在推流
object CurrentFrame, // 当前帧数据
int Width, // 图像宽度(px
int Height, // 图像高度(px
double FrameRate) // 帧率fps
{
public static readonly CameraState Default = new(false, false, null, 0, 0, 0);
}
/// <summary>物理坐标</summary>
/// <summary>物理坐标位置</summary>
public record PhysicalPosition(double X, double Y, double Z);
/// <summary>图像标定矩阵,像素坐标 → 物理坐标映射</summary>
/// <summary>标定矩阵3×3 仿射变换)</summary>
public record CalibrationMatrix(
double M11, double M12, double M13, // 3x3 仿射变换矩阵
double M11, double M12, double M13,
double M21, double M22, double M23,
double M31, double M32, double M33
)
double M31, double M32, double M33)
{
/// <summary>将像素坐标换为物理坐标</summary>
/// <summary>将像素坐标换为物理坐标</summary>
public (double X, double Y, double Z) Transform(double pixelX, double pixelY)
{
double x = M11 * pixelX + M12 * pixelY + M13;
@@ -108,12 +109,11 @@ namespace XplorePlane.Models
}
}
/// <summary>画面联动状态(不可变)</summary>
/// <summary>联动视图状态(不可变)</summary>
public record LinkedViewState(
PhysicalPosition TargetPosition, // 目标物理坐标
bool IsExecuting, // 联动是否正在执行
DateTime LastRequestTime // 最近一次联动请求时间
)
PhysicalPosition TargetPosition, // 目标物理位置
bool IsExecuting, // 是否正在执行移动
DateTime LastRequestTime) // 最近一次请求时间
{
public static readonly LinkedViewState Default = new(new PhysicalPosition(0, 0, 0), false, DateTime.MinValue);
}
+387 -50
View File
@@ -1,29 +1,54 @@
using Prism.Events;
using Prism.Mvvm;
using System;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
using XP.Common.Logging.Interfaces;
using XP.Hardware.Detector.Abstractions;
using XP.Hardware.Detector.Abstractions.Enums;
using XP.Hardware.Detector.Abstractions.Events;
using XP.Hardware.Detector.Services;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Abstractions.Events;
using XP.Hardware.MotionControl.Services;
using XP.Hardware.RaySource.Abstractions.Enums;
using XP.Hardware.RaySource.Abstractions.Events;
using XP.Hardware.RaySource.Services;
using XplorePlane.Events;
using XplorePlane.Models;
namespace XplorePlane.Services.AppState
{
/// <summary>
/// 全局应用状态管理服务实现。
/// 继承 BindableBase 以支持 WPF 数据绑定,使用 Interlocked.Exchange 保证线程安全写入,
/// 通过 Dispatcher.BeginInvoke 将事件调度到 UI 线程。
/// Global application state service.
/// Motion state is synchronized from the motion hardware service layer and
/// mapped into the legacy business model for compatibility.
/// Detector state and latest frame are synchronized via Prism EventAggregator subscriptions.
/// </summary>
public class AppStateService : BindableBase, IAppStateService
{
private readonly Dispatcher _dispatcher;
private readonly IRaySourceService _raySourceService;
private readonly IMotionSystem _motionSystem;
private readonly IMotionControlService _motionControlService;
private readonly IDetectorService _detectorService;
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
private readonly SubscriptionToken _axisStatusChangedToken;
private readonly SubscriptionToken _geometryUpdatedToken;
private readonly SubscriptionToken _detectorStatusChangedToken;
private readonly SubscriptionToken _detectorImageCapturedToken;
private readonly SubscriptionToken _raySourceStatusUpdatedToken;
private readonly SubscriptionToken _raySourceStatusChangedToken;
private readonly SubscriptionToken _raySourceVariablesConnectedToken;
private bool _disposed;
private GeometryData _latestGeometry;
// ── 状态字段(通过 Interlocked.Exchange 原子替换)──
private MotionState _motionState = MotionState.Default;
private RaySourceState _raySourceState = RaySourceState.Default;
private DetectorState _detectorState = DetectorState.Default;
private SystemState _systemState = SystemState.Default;
@@ -32,26 +57,21 @@ namespace XplorePlane.Services.AppState
private LinkedViewState _linkedViewState = LinkedViewState.Default;
private RecipeExecutionState _recipeExecutionState = RecipeExecutionState.Default;
// ── 探测器最新帧(volatile,供任意线程读取)──
private volatile ImageCapturedEventArgs _latestDetectorFrame;
// ── 类型化状态变更事件 ──
public event EventHandler<StateChangedEventArgs<MotionState>> MotionStateChanged;
public event EventHandler<StateChangedEventArgs<RaySourceState>> RaySourceStateChanged;
public event EventHandler<StateChangedEventArgs<DetectorState>> DetectorStateChanged;
public event EventHandler<StateChangedEventArgs<SystemState>> SystemStateChanged;
public event EventHandler<StateChangedEventArgs<CameraState>> CameraStateChanged;
public event EventHandler<StateChangedEventArgs<LinkedViewState>> LinkedViewStateChanged;
public event EventHandler<StateChangedEventArgs<RecipeExecutionState>> RecipeExecutionStateChanged;
public event EventHandler<LinkedViewRequestEventArgs> LinkedViewRequested;
// ── 状态属性(只读)──
public MotionState MotionState => _motionState;
public RaySourceState RaySourceState => _raySourceState;
public DetectorState DetectorState => _detectorState;
public SystemState SystemState => _systemState;
@@ -60,19 +80,69 @@ namespace XplorePlane.Services.AppState
public LinkedViewState LinkedViewState => _linkedViewState;
public RecipeExecutionState RecipeExecutionState => _recipeExecutionState;
/// <summary>
/// 探测器最新采集帧(线程安全,可从任意线程读取)。
/// 由 ImageCapturedEvent 驱动更新,无采集时为 null。
/// </summary>
public ImageCapturedEventArgs LatestDetectorFrame => _latestDetectorFrame;
public AppStateService(
IRaySourceService raySourceService,
IMotionSystem motionSystem,
IMotionControlService motionControlService,
IDetectorService detectorService,
IEventAggregator eventAggregator,
ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(raySourceService);
ArgumentNullException.ThrowIfNull(motionSystem);
ArgumentNullException.ThrowIfNull(motionControlService);
ArgumentNullException.ThrowIfNull(detectorService);
ArgumentNullException.ThrowIfNull(eventAggregator);
ArgumentNullException.ThrowIfNull(logger);
_raySourceService = raySourceService;
_motionSystem = motionSystem;
_motionControlService = motionControlService;
_detectorService = detectorService;
_eventAggregator = eventAggregator;
_logger = logger.ForModule<AppStateService>();
_dispatcher = Application.Current.Dispatcher;
_dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
// ── 运动控制事件订阅 ──
_geometryUpdatedToken = _eventAggregator
.GetEvent<GeometryUpdatedEvent>()
.Subscribe(OnGeometryUpdated);
_axisStatusChangedToken = _eventAggregator
.GetEvent<AxisStatusChangedEvent>()
.Subscribe(OnAxisStatusChanged);
// ── 探测器状态事件订阅(后台线程,避免阻塞采集)──
_detectorStatusChangedToken = _eventAggregator
.GetEvent<StatusChangedEvent>()
.Subscribe(OnDetectorStatusChanged, ThreadOption.BackgroundThread);
// ── 探测器图像事件订阅(后台线程,仅缓存最新帧)──
_detectorImageCapturedToken = _eventAggregator
.GetEvent<ImageCapturedEvent>()
.Subscribe(OnDetectorImageCaptured, ThreadOption.BackgroundThread);
// ── 射线源状态事件订阅(后台线程)──
_raySourceStatusUpdatedToken = _eventAggregator
.GetEvent<StatusUpdatedEvent>()
.Subscribe(OnRaySourceStatusUpdated, ThreadOption.BackgroundThread);
_raySourceStatusChangedToken = _eventAggregator
.GetEvent<RaySourceStatusChangedEvent>()
.Subscribe(OnRaySourceStatusChanged, ThreadOption.BackgroundThread);
_raySourceVariablesConnectedToken = _eventAggregator
.GetEvent<VariablesConnectedEvent>()
.Subscribe(OnRaySourceVariablesConnected, ThreadOption.BackgroundThread);
SubscribeToExistingServices();
_logger.Info("AppStateService 已初始化");
_logger.Info("AppStateService initialized");
}
// ── 状态更新方法 ──
@@ -80,17 +150,30 @@ namespace XplorePlane.Services.AppState
public void UpdateMotionState(MotionState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateMotionState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateMotionState");
return;
}
var old = Interlocked.Exchange(ref _motionState, newState);
if (ReferenceEquals(old, newState)) return;
RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState));
// Keep the legacy API surface, but let the hardware service layer
// remain the source of truth whenever a fresh hardware snapshot is available.
if (TryRefreshMotionStateFromHardware("UpdateMotionState"))
{
return;
}
SetMotionState(newState);
}
public void UpdateRaySourceState(RaySourceState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateRaySourceState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateRaySourceState");
return;
}
var old = Interlocked.Exchange(ref _raySourceState, newState);
if (ReferenceEquals(old, newState)) return;
@@ -100,7 +183,11 @@ namespace XplorePlane.Services.AppState
public void UpdateDetectorState(DetectorState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateDetectorState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateDetectorState");
return;
}
var old = Interlocked.Exchange(ref _detectorState, newState);
if (ReferenceEquals(old, newState)) return;
@@ -110,7 +197,11 @@ namespace XplorePlane.Services.AppState
public void UpdateSystemState(SystemState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateSystemState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateSystemState");
return;
}
var old = Interlocked.Exchange(ref _systemState, newState);
if (ReferenceEquals(old, newState)) return;
@@ -120,7 +211,11 @@ namespace XplorePlane.Services.AppState
public void UpdateCameraState(CameraState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateCameraState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateCameraState");
return;
}
var old = Interlocked.Exchange(ref _cameraState, newState);
if (ReferenceEquals(old, newState)) return;
@@ -130,21 +225,26 @@ namespace XplorePlane.Services.AppState
public void UpdateCalibrationMatrix(CalibrationMatrix newMatrix)
{
ArgumentNullException.ThrowIfNull(newMatrix);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateCalibrationMatrix 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateCalibrationMatrix");
return;
}
var old = Interlocked.Exchange(ref _calibrationMatrix, newMatrix);
if (ReferenceEquals(old, newMatrix)) return;
_dispatcher.BeginInvoke(() =>
{
RaisePropertyChanged(nameof(CalibrationMatrix));
});
_dispatcher.BeginInvoke(() => RaisePropertyChanged(nameof(CalibrationMatrix)));
}
public void UpdateLinkedViewState(LinkedViewState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateLinkedViewState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateLinkedViewState");
return;
}
var old = Interlocked.Exchange(ref _linkedViewState, newState);
if (ReferenceEquals(old, newState)) return;
@@ -154,7 +254,11 @@ namespace XplorePlane.Services.AppState
public void UpdateRecipeExecutionState(RecipeExecutionState newState)
{
ArgumentNullException.ThrowIfNull(newState);
if (_disposed) { _logger.Warn("AppStateService 已释放,忽略 UpdateRecipeExecutionState 调用"); return; }
if (_disposed)
{
_logger.Warn("AppStateService is disposed, ignoring UpdateRecipeExecutionState");
return;
}
var old = Interlocked.Exchange(ref _recipeExecutionState, newState);
if (ReferenceEquals(old, newState)) return;
@@ -168,11 +272,11 @@ namespace XplorePlane.Services.AppState
var matrix = _calibrationMatrix;
if (matrix is null)
{
_logger.Warn("CalibrationMatrix 未设置,无法执行画面联动 (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY);
_logger.Warn("CalibrationMatrix is not configured, cannot execute linked view request (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY);
UpdateSystemState(SystemState with
{
HasError = true,
ErrorMessage = "CalibrationMatrix 未设置,无法执行画面联动"
ErrorMessage = "CalibrationMatrix is not configured, cannot execute linked view request"
});
return;
}
@@ -191,10 +295,63 @@ namespace XplorePlane.Services.AppState
});
}
// ── 内部辅助方法 ──
public void Dispose()
{
if (_disposed) return;
_disposed = true;
private void RaiseOnDispatcher<T>(T oldVal, T newVal,
EventHandler<StateChangedEventArgs<T>> handler, string propertyName)
if (_axisStatusChangedToken is not null)
{
_eventAggregator.GetEvent<AxisStatusChangedEvent>().Unsubscribe(_axisStatusChangedToken);
}
if (_geometryUpdatedToken is not null)
{
_eventAggregator.GetEvent<GeometryUpdatedEvent>().Unsubscribe(_geometryUpdatedToken);
}
if (_detectorStatusChangedToken is not null)
{
_eventAggregator.GetEvent<StatusChangedEvent>().Unsubscribe(_detectorStatusChangedToken);
}
if (_detectorImageCapturedToken is not null)
{
_eventAggregator.GetEvent<ImageCapturedEvent>().Unsubscribe(_detectorImageCapturedToken);
}
if (_raySourceStatusUpdatedToken is not null)
{
_eventAggregator.GetEvent<StatusUpdatedEvent>().Unsubscribe(_raySourceStatusUpdatedToken);
}
if (_raySourceStatusChangedToken is not null)
{
_eventAggregator.GetEvent<RaySourceStatusChangedEvent>().Unsubscribe(_raySourceStatusChangedToken);
}
if (_raySourceVariablesConnectedToken is not null)
{
_eventAggregator.GetEvent<VariablesConnectedEvent>().Unsubscribe(_raySourceVariablesConnectedToken);
}
MotionStateChanged = null;
RaySourceStateChanged = null;
DetectorStateChanged = null;
SystemStateChanged = null;
CameraStateChanged = null;
LinkedViewStateChanged = null;
RecipeExecutionStateChanged = null;
LinkedViewRequested = null;
_logger.Info("AppStateService disposed");
}
private void RaiseOnDispatcher<T>(
T oldVal,
T newVal,
EventHandler<StateChangedEventArgs<T>> handler,
string propertyName)
{
_dispatcher.BeginInvoke(() =>
{
@@ -205,34 +362,214 @@ namespace XplorePlane.Services.AppState
}
catch (Exception ex)
{
_logger.Error(ex, "状态变更事件处理器抛出异常 (property={PropertyName})", propertyName);
_logger.Error(ex, "State changed handler failed (property={PropertyName})", propertyName);
}
});
}
private void SubscribeToExistingServices()
{
_logger.Info("AppStateService 已准备好接收外部服务状态更新");
if (TryRefreshMotionStateFromHardware("initialization"))
{
_logger.Info("AppStateService subscribed to motion hardware state");
return;
}
_logger.Warn("AppStateService could not initialize motion state from hardware");
}
// ── Dispose ──
public void Dispose()
private void OnAxisStatusChanged(AxisStatusChangedData _)
{
if (_disposed) return;
_disposed = true;
TryRefreshMotionStateFromHardware("axis-status-changed");
}
// 清除所有事件订阅
MotionStateChanged = null;
RaySourceStateChanged = null;
DetectorStateChanged = null;
SystemStateChanged = null;
CameraStateChanged = null;
LinkedViewStateChanged = null;
RecipeExecutionStateChanged = null;
LinkedViewRequested = null;
private void OnGeometryUpdated(GeometryData geometry)
{
if (_disposed) return;
_logger.Info("AppStateService 已释放");
_latestGeometry = geometry;
TryRefreshMotionStateFromHardware("geometry-updated");
}
/// <summary>
/// 探测器状态变更回调。
/// 将硬件层 DetectorStatus 映射为应用层 DetectorState 并同步到 AppState。
/// 运行在后台线程(ThreadOption.BackgroundThread),不阻塞采集。
/// </summary>
private void OnDetectorStatusChanged(DetectorStatus status)
{
if (_disposed) return;
// 在更新状态前记录当前连接状态,用于检测断连转换
bool wasConnected = _detectorState?.IsConnected ?? false;
// 从 IDetectorService 读取分辨率等补充信息
string resolution = string.Empty;
double frameRate = 0;
try
{
var info = _detectorService.GetInfo();
if (info != null)
resolution = $"{info.MaxWidth}x{info.MaxHeight}";
}
catch
{
// 探测器未初始化时 GetInfo 会抛出,忽略即可
}
bool isConnected = status != DetectorStatus.Uninitialized && status != DetectorStatus.Error;
bool isAcquiring = status == DetectorStatus.Acquiring;
var newState = new DetectorState(
IsConnected: isConnected,
IsAcquiring: isAcquiring,
FrameRate: frameRate,
Resolution: resolution);
UpdateDetectorState(newState);
_logger.Info(
"探测器状态已同步:{Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring} | " +
"Detector state synced: {Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring}",
status, isConnected, isAcquiring);
// 检测从已连接变为断开,发布断连事件
if (wasConnected && !isConnected)
{
_eventAggregator.GetEvent<DetectorDisconnectedEvent>().Publish();
_logger.Warn("探测器已断连,发布 DetectorDisconnectedEvent | Detector disconnected, publishing DetectorDisconnectedEvent");
}
}
/// <summary>
/// 探测器图像采集回调。
/// 仅缓存最新帧引用(volatile 写),不做任何图像处理,保持采集链路零阻塞。
/// 上层通过 LatestDetectorFrame 属性按需读取。
/// </summary>
private void OnDetectorImageCaptured(ImageCapturedEventArgs args)
{
if (_disposed || args?.ImageData == null) return;
_latestDetectorFrame = args;
}
/// <summary>
/// 射线源全量状态更新回调(主路径)。
/// StatusUpdatedEvent 携带实际电压、电流和开关状态,是 RaySourceState 的主要数据来源。
/// Power(W) = ActualVoltage(kV) × ActualCurrent(μA) / 1000
/// </summary>
private void OnRaySourceStatusUpdated(SystemStatusData data)
{
if (_disposed || data == null) return;
double power = data.ActualVoltage * data.ActualCurrent / 1000.0;
UpdateRaySourceState(new RaySourceState(
IsOn: data.IsXRayOn,
Voltage: data.ActualVoltage,
Power: power));
}
/// <summary>
/// 射线源三态状态变更回调(补充路径)。
/// RaySourceStatusChangedEvent 仅携带枚举状态,用于在 StatusUpdatedEvent 尚未到达时
/// 快速同步 IsOn 字段,电压和功率保持当前值不变。
/// </summary>
private void OnRaySourceStatusChanged(RaySourceStatus status)
{
if (_disposed) return;
bool isOn = status == RaySourceStatus.Opened;
var current = _raySourceState;
// 仅当 IsOn 状态发生变化时才更新,避免覆盖 StatusUpdatedEvent 写入的精确数据
if (current.IsOn == isOn) return;
UpdateRaySourceState(new RaySourceState(
IsOn: isOn,
Voltage: current.Voltage,
Power: current.Power));
_logger.Info(
"射线源状态变更:{Status} → IsOn={IsOn} | RaySource status changed: {Status} → IsOn={IsOn}",
status, isOn);
}
/// <summary>
/// 射线源 PVI 变量连接状态变更回调。
/// 断开时将 RaySourceState 重置为默认值(IsOn=false, Voltage=0, Power=0)。
/// </summary>
private void OnRaySourceVariablesConnected(bool isConnected)
{
if (_disposed) return;
if (!isConnected)
{
UpdateRaySourceState(RaySourceState.Default);
_logger.Warn("射线源 PVI 变量已断开,RaySourceState 已重置 | RaySource PVI variables disconnected, RaySourceState reset");
}
else
{
_logger.Info("射线源 PVI 变量已连接 | RaySource PVI variables connected");
}
}
private bool TryRefreshMotionStateFromHardware(string reason)
{
try
{
if (_latestGeometry is null)
{
var geometry = _motionControlService.GetCurrentGeometry();
_latestGeometry = new GeometryData(geometry.FOD, geometry.FDD, geometry.Magnification);
}
SetMotionState(BuildMotionStateSnapshot(_latestGeometry));
return true;
}
catch (Exception ex)
{
_logger.Warn("Failed to refresh motion state from hardware during {Reason}: {Message}", reason, ex.Message);
_logger.Error(ex, "Motion state refresh exception during {Reason}", reason);
return false;
}
}
private MotionState BuildMotionStateSnapshot(GeometryData geometry)
{
var stageX = _motionSystem.GetLinearAxis(AxisId.StageX);
var stageY = _motionSystem.GetLinearAxis(AxisId.StageY);
var sourceZ = _motionSystem.GetLinearAxis(AxisId.SourceZ);
var detectorZ = _motionSystem.GetLinearAxis(AxisId.DetectorZ);
var detectorSwing = _motionSystem.GetRotaryAxis(RotaryAxisId.DetectorSwing);
var stageRotation = _motionSystem.GetRotaryAxis(RotaryAxisId.StageRotation);
var fixtureRotation = _motionSystem.GetRotaryAxis(RotaryAxisId.FixtureRotation);
return new MotionState(
StageX: stageX.ActualPosition,
StageY: stageY.ActualPosition,
SourceZ: sourceZ.ActualPosition,
DetectorZ: detectorZ.ActualPosition,
DetectorSwing: detectorSwing.ActualAngle,
FDD: geometry?.FDD ?? 0,
StageXSpeed: 0,
StageYSpeed: 0,
SourceZSpeed: 0,
DetectorZSpeed: 0,
DetectorSwingSpeed: 0,
FDDSpeed: 0,
StageRotation: stageRotation.ActualAngle,
FixtureRotation: fixtureRotation.ActualAngle,
FOD: geometry?.FOD ?? 0,
Magnification: geometry?.Magnification ?? 0,
StageRotationSpeed: 0,
FixtureRotationSpeed: 0);
}
private void SetMotionState(MotionState newState)
{
var old = Interlocked.Exchange(ref _motionState, newState);
if (ReferenceEquals(old, newState)) return;
RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState));
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.ComponentModel;
using XP.Hardware.Detector.Abstractions;
using XplorePlane.Models;
namespace XplorePlane.Services.AppState
@@ -21,6 +22,13 @@ namespace XplorePlane.Services.AppState
LinkedViewState LinkedViewState { get; }
RecipeExecutionState RecipeExecutionState { get; }
/// <summary>
/// 探测器最新采集帧(线程安全,可从任意线程读取)。
/// 由 ImageCapturedEvent 驱动更新,探测器未采集时为 null。
/// CNC 执行、图像处理等上层服务通过此属性按需取图,无需自行订阅事件。
/// </summary>
ImageCapturedEventArgs LatestDetectorFrame { get; }
// ── 状态更新方法(线程安全,可从任意线程调用)──
void UpdateMotionState(MotionState newState);
+160 -66
View File
@@ -6,9 +6,12 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using XP.Common.GeneralForm.Views;
using Prism.Events;
using XP.Common.Converters;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.MainViewport;
using XplorePlane.ViewModels;
@@ -23,34 +26,63 @@ namespace XplorePlane.Services.Cnc
private readonly IInspectionResultStore _store;
private readonly ILoggerService _logger;
private readonly IMainViewportService _mainViewportService;
private readonly IAppStateService _appStateService;
private readonly IPipelineExecutionService _pipelineExecutionService;
private readonly IImageProcessingService _imageProcessingService;
private readonly IEventAggregator _eventAggregator;
// Task 4.2: volatile field so reads/writes are not reordered across threads
private volatile CancellationTokenSource _executionCts;
public CncExecutionService(
IInspectionResultStore store,
ILoggerService logger,
IMainViewportService mainViewportService,
IAppStateService appStateService,
IPipelineExecutionService pipelineExecutionService,
IImageProcessingService imageProcessingService)
IImageProcessingService imageProcessingService,
IEventAggregator eventAggregator)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainViewportService = mainViewportService;
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
_pipelineExecutionService = pipelineExecutionService;
_imageProcessingService = imageProcessingService;
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
.Subscribe(OnDetectorDisconnected, ThreadOption.BackgroundThread);
}
// Task 4.3: callback cancel the running execution when the detector disconnects
private void OnDetectorDisconnected()
{
var cts = _executionCts;
if (cts == null) return;
try
{
cts.Cancel();
_logger.ForModule<CncExecutionService>().Warn("探测器断连,已取消当前 CNC 执行");
}
catch (ObjectDisposedException) { }
}
public async Task ExecuteAsync(CncProgram program, IProgress<CncNodeExecutionProgress> progress, CancellationToken cancellationToken)
{
// Pre-cancellation check — do NOT call BeginRunAsync if already cancelled
if (cancellationToken.IsCancellationRequested)
// Task 4.4: create a linked CTS so DetectorDisconnectedEvent can cancel independently
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executionCts = linkedCts;
_mainViewportService?.SetCncRunning(true);
try
{
// Pre-cancellation check - do NOT call BeginRunAsync if already cancelled
if (linkedCts.Token.IsCancellationRequested)
return;
int inspectionNodeCount = program.Nodes.OfType<InspectionModuleNode>().Count();
// 获取当前源图像(用于 run/source.bmp
var sourceImage = _mainViewportService?.LatestManualImage as BitmapSource
?? _mainViewportService?.CurrentDisplayImage as BitmapSource;
var sourceImage = TryGetSourceImage();
Guid runId;
try
@@ -90,13 +122,13 @@ namespace XplorePlane.Services.Cnc
foreach (var node in program.Nodes.OrderBy(n => n.Index))
{
if (cancellationToken.IsCancellationRequested)
if (linkedCts.Token.IsCancellationRequested)
{
cancelled = true;
break;
}
progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running));
progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running, ProgressPercent: 0));
bool nodeSucceeded = true;
@@ -104,10 +136,67 @@ namespace XplorePlane.Services.Cnc
{
switch (node)
{
case ReferencePointNode rp:
_logger.ForModule<CncExecutionService>().Info(
"Executing reference point node [{Index}] {Name} | " +
"StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
"DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
"StageRotation={StageRotation} FixtureRotation={FixtureRotation} " +
"RayOn={RayOn} Voltage={Voltage}kV Current={Current}uA",
rp.Index, rp.Name,
rp.StageX, rp.StageY, rp.SourceZ, rp.DetectorZ,
rp.DetectorSwing, rp.FDD, rp.FOD, rp.Magnification,
rp.StageRotation, rp.FixtureRotation,
rp.IsRayOn, rp.Voltage, rp.Current);
break;
case SavePositionNode sp:
_logger.ForModule<CncExecutionService>().Info(
"Executing save-position node [{Index}] {Name} | " +
"StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
"DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
"StageRotation={StageRotation} FixtureRotation={FixtureRotation}",
sp.Index, sp.Name,
sp.MotionState.StageX, sp.MotionState.StageY,
sp.MotionState.SourceZ, sp.MotionState.DetectorZ,
sp.MotionState.DetectorSwing, sp.MotionState.FDD,
sp.MotionState.FOD, sp.MotionState.Magnification,
sp.MotionState.StageRotation, sp.MotionState.FixtureRotation);
break;
case SaveNodeNode sn:
_logger.ForModule<CncExecutionService>().Info(
"Executing save node [{Index}] {Name} | " +
"StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
"DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
"RayOn={RayOn} Voltage={Voltage}kV Power={Power}W",
sn.Index, sn.Name,
sn.MotionState.StageX, sn.MotionState.StageY,
sn.MotionState.SourceZ, sn.MotionState.DetectorZ,
sn.MotionState.DetectorSwing, sn.MotionState.FDD,
sn.MotionState.FOD, sn.MotionState.Magnification,
sn.RaySourceState.IsOn, sn.RaySourceState.Voltage, sn.RaySourceState.Power);
break;
case SaveNodeWithImageNode sni:
_logger.ForModule<CncExecutionService>().Info(
"Executing save-with-image node [{Index}] {Name} | " +
"StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
"DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
"RayOn={RayOn} Voltage={Voltage}kV Power={Power}W ImageFile={ImageFile}",
sni.Index, sni.Name,
sni.MotionState.StageX, sni.MotionState.StageY,
sni.MotionState.SourceZ, sni.MotionState.DetectorZ,
sni.MotionState.DetectorSwing, sni.MotionState.FDD,
sni.MotionState.FOD, sni.MotionState.Magnification,
sni.RaySourceState.IsOn, sni.RaySourceState.Voltage, sni.RaySourceState.Power,
sni.ImageFileName);
break;
case WaitDelayNode waitNode:
try
{
await ExecuteWaitDelayWithProgressAsync(waitNode, cancellationToken);
await ExecuteWaitDelayWithProgressAsync(waitNode, progress, linkedCts.Token);
}
catch (OperationCanceledException)
{
@@ -118,14 +207,14 @@ namespace XplorePlane.Services.Cnc
case PauseDialogNode pauseNode:
await Application.Current.Dispatcher.InvokeAsync(() =>
MessageBox.Show(pauseNode.DialogMessage, pauseNode.DialogTitle));
if (cancellationToken.IsCancellationRequested)
if (linkedCts.Token.IsCancellationRequested)
cancelled = true;
break;
case InspectionModuleNode inspectionNode:
try
{
var img = await ExecuteInspectionNodeAsync(runId, inspectionNode, sourceImage, cancellationToken);
var img = await ExecuteInspectionNodeAsync(runId, inspectionNode, sourceImage, linkedCts.Token);
if (img != null) lastResultImage = img;
}
catch (Exception ex)
@@ -150,7 +239,10 @@ namespace XplorePlane.Services.Cnc
{
_logger.ForModule<CncExecutionService>().Error(ex,
"Unexpected error executing node '{0}' (Id={1})", node.Name, node.Id);
nodeSucceeded = false;
if (linkedCts.Token.IsCancellationRequested)
cancelled = true;
else
nodeSucceeded = false;
}
if (cancelled)
@@ -159,8 +251,8 @@ namespace XplorePlane.Services.Cnc
break;
}
// InspectionModuleNode 完成时携带结果图像,供 ViewModel 缓存到节点上
var nodeResultImage = (node is InspectionModuleNode) ? lastResultImage : null;
// Carry the latest inspection result image so the ViewModel can cache it on the node.
var nodeResultImage = node is InspectionModuleNode ? lastResultImage : null;
var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed;
progress?.Report(new CncNodeExecutionProgress(node.Id, finalState, nodeResultImage));
@@ -168,7 +260,7 @@ namespace XplorePlane.Services.Cnc
allSucceeded = false;
}
endLoop:
endLoop:
bool? overallPass = cancelled ? null : (bool?)allSucceeded;
@@ -181,6 +273,31 @@ namespace XplorePlane.Services.Cnc
_logger.ForModule<CncExecutionService>().Error(ex,
"Failed to complete inspection run '{0}'", runId);
}
} // end try
finally
{
_executionCts = null;
_mainViewportService?.SetCncRunning(false);
}
}
private BitmapSource TryGetSourceImage()
{
var viewportImage = _mainViewportService?.LatestManualImage as BitmapSource
?? _mainViewportService?.CurrentDisplayImage as BitmapSource;
if (viewportImage != null)
return viewportImage;
var detectorFrame = _appStateService?.LatestDetectorFrame;
if (detectorFrame?.ImageData == null || detectorFrame.Width <= 0 || detectorFrame.Height <= 0)
return null;
var bitmap = XP.Common.Converters.ImageConverter.ConvertGray16ToBitmapSource(
detectorFrame.ImageData,
(int)detectorFrame.Width,
(int)detectorFrame.Height);
bitmap.Freeze();
return bitmap;
}
private async Task<BitmapSource> ExecuteInspectionNodeAsync(
@@ -211,10 +328,8 @@ namespace XplorePlane.Services.Cnc
};
}
// 构建资产列表
var assets = new System.Collections.Generic.List<InspectionAssetWriteRequest>();
// input.bmp — 当前源图像
if (sourceImage != null)
{
assets.Add(new InspectionAssetWriteRequest
@@ -227,7 +342,6 @@ namespace XplorePlane.Services.Cnc
});
}
// result_overlay.bmp — 执行流水线后的结果图像
BitmapSource resultImage = null;
if (_pipelineExecutionService != null && inspectionNode.Pipeline?.Nodes?.Count > 0 && sourceImage != null)
{
@@ -248,9 +362,7 @@ namespace XplorePlane.Services.Cnc
Height = resultImage.PixelHeight
});
nodeResult.Status = InspectionNodeStatus.Succeeded;
// 执行完立即更新主视口
_mainViewportService?.SetManualImage(resultImage, $"CNC节点:{inspectionNode.Name}");
_mainViewportService?.SetManualImage(resultImage, $"CNC Node: {inspectionNode.Name}");
}
}
catch (Exception ex)
@@ -265,21 +377,20 @@ namespace XplorePlane.Services.Cnc
return resultImage;
}
private System.Collections.Generic.IEnumerable<ViewModels.PipelineNodeViewModel> BuildPipelineNodeViewModels(PipelineModel pipeline)
private System.Collections.Generic.IEnumerable<PipelineNodeViewModel> BuildPipelineNodeViewModels(PipelineModel pipeline)
{
var nodes = new System.Collections.Generic.List<ViewModels.PipelineNodeViewModel>();
var nodes = new System.Collections.Generic.List<PipelineNodeViewModel>();
if (pipeline?.Nodes == null) return nodes;
foreach (var nodeModel in pipeline.Nodes.OrderBy(n => n.Order))
{
var displayName = _imageProcessingService?.GetProcessorDisplayName(nodeModel.OperatorKey) ?? nodeModel.OperatorKey;
var vm = new ViewModels.PipelineNodeViewModel(nodeModel.OperatorKey, displayName, string.Empty)
var vm = new PipelineNodeViewModel(nodeModel.OperatorKey, displayName, string.Empty)
{
Order = nodeModel.Order,
IsEnabled = nodeModel.IsEnabled
};
// 加载参数定义并恢复保存的值
if (_imageProcessingService != null)
{
var paramDefs = _imageProcessingService.GetProcessorParameters(nodeModel.OperatorKey);
@@ -287,7 +398,7 @@ namespace XplorePlane.Services.Cnc
{
foreach (var def in paramDefs)
{
var paramVm = new ViewModels.ProcessorParameterVM(def);
var paramVm = new ProcessorParameterVM(def);
if (nodeModel.Parameters != null && nodeModel.Parameters.TryGetValue(def.Name, out var saved))
paramVm.Value = ConvertSavedValue(saved, def.ValueType);
vm.Parameters.Add(paramVm);
@@ -300,19 +411,16 @@ namespace XplorePlane.Services.Cnc
return nodes;
}
/// <summary>
/// 将 JSON 反序列化后的 JsonElement 转换为参数所需的实际类型。
/// </summary>
private static object ConvertSavedValue(object savedValue, Type targetType)
{
if (savedValue is not System.Text.Json.JsonElement jsonElement)
if (savedValue is not JsonElement jsonElement)
return savedValue;
try
{
if (targetType == typeof(int)) return jsonElement.GetInt32();
if (targetType == typeof(int)) return jsonElement.GetInt32();
if (targetType == typeof(double)) return jsonElement.GetDouble();
if (targetType == typeof(bool)) return jsonElement.GetBoolean();
if (targetType == typeof(bool)) return jsonElement.GetBoolean();
if (targetType == typeof(string)) return jsonElement.GetString() ?? string.Empty;
return jsonElement.ToString();
}
@@ -331,47 +439,33 @@ namespace XplorePlane.Services.Cnc
return ms.ToArray();
}
private static async Task ExecuteWaitDelayWithProgressAsync(WaitDelayNode waitNode, CancellationToken cancellationToken)
private static async Task ExecuteWaitDelayWithProgressAsync(
WaitDelayNode waitNode,
IProgress<CncNodeExecutionProgress> progress,
CancellationToken cancellationToken)
{
int totalMs = waitNode.DelayMilliseconds;
if (totalMs <= 0)
{
progress?.Report(new CncNodeExecutionProgress(waitNode.Id, NodeExecutionState.Running, ProgressPercent: 100));
return;
}
const int tickMs = 50;
ProgressWindow progressWindow = null;
await Application.Current.Dispatcher.InvokeAsync(() =>
int elapsed = 0;
while (elapsed < totalMs)
{
progressWindow = new ProgressWindow(
title: "延时等待",
message: $"节点:{waitNode.Name} 等待 {totalMs / 1000.0:F1} 秒",
isCancelable: false);
progressWindow.Owner = Application.Current.MainWindow;
progressWindow.Show();
});
cancellationToken.ThrowIfCancellationRequested();
try
{
int elapsed = 0;
while (elapsed < totalMs)
{
cancellationToken.ThrowIfCancellationRequested();
int remaining = totalMs - elapsed;
int delay = Math.Min(tickMs, remaining);
await Task.Delay(delay, cancellationToken);
elapsed += delay;
double pct = Math.Min(100.0 * elapsed / totalMs, 100.0);
double remainSec = Math.Max(0, (totalMs - elapsed) / 1000.0);
string msg = $"节点:{waitNode.Name} 剩余 {remainSec:F1} 秒";
progressWindow?.UpdateProgress(msg, pct);
}
}
finally
{
progressWindow?.Close();
int remaining = totalMs - elapsed;
int delay = Math.Min(tickMs, remaining);
await Task.Delay(delay, cancellationToken);
elapsed += delay;
progress?.Report(new CncNodeExecutionProgress(
waitNode.Id,
NodeExecutionState.Running,
ProgressPercent: elapsed * 100d / totalMs));
}
}
}
+12 -8
View File
@@ -389,22 +389,26 @@ namespace XplorePlane.Services.Cnc
var raySource = _appStateService.RaySourceState;
return new ReferencePointNode(
id, index, $"参考点_{index}",
XM: motion.XM,
YM: motion.YM,
ZT: motion.ZT,
ZD: motion.ZD,
TiltD: motion.TiltD,
Dist: motion.Dist,
StageX: motion.StageX,
StageY: motion.StageY,
SourceZ: motion.SourceZ,
DetectorZ: motion.DetectorZ,
DetectorSwing: motion.DetectorSwing,
FDD: motion.FDD,
IsRayOn: raySource.IsOn,
Voltage: raySource.Voltage,
Current: TryReadCurrent());
Current: TryReadCurrent(),
StageRotation: motion.StageRotation,
FixtureRotation: motion.FixtureRotation,
FOD: motion.FOD,
Magnification: motion.Magnification);
}
/// <summary>创建保存节点(含图像)| Create save node with image</summary>
private SaveNodeWithImageNode CreateSaveNodeWithImageNode(Guid id, int index)
{
return new SaveNodeWithImageNode(
id, index, $"保存节点(图像)_{index}",
id, index, $"保存节点_图像_{index}",
MotionState: _appStateService.MotionState,
RaySourceState: _appStateService.RaySourceState,
DetectorState: _appStateService.DetectorState,
@@ -17,6 +17,11 @@ namespace XplorePlane.Services.Cnc
/// <summary>
/// Progress report for a single CNC node execution.
/// ResultImage is non-null when an InspectionModuleNode produces output.
/// ProgressPercent is used by long-running nodes such as WaitDelayNode.
/// </summary>
public record CncNodeExecutionProgress(Guid NodeId, NodeExecutionState State, BitmapSource ResultImage = null);
public record CncNodeExecutionProgress(
Guid NodeId,
NodeExecutionState State,
BitmapSource ResultImage = null,
double? ProgressPercent = null);
}
@@ -10,6 +10,7 @@ using System.Threading.Tasks;
using XP.Common.Database.Interfaces;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.Storage;
namespace XplorePlane.Services.InspectionResults
{
@@ -160,6 +161,14 @@ WHERE run_id = @run_id";
"InspectionResults");
}
public InspectionResultStore(IDbContext db, ILoggerService logger, IXpDataPathService dataPathService)
: this(
db,
logger,
Path.Combine(dataPathService?.DataPath ?? throw new ArgumentNullException(nameof(dataPathService)), "InspectionResults"))
{
}
public async Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null)
{
ArgumentNullException.ThrowIfNull(runRecord);
@@ -29,14 +29,32 @@ namespace XplorePlane.Services.MainViewport
IEventAggregator eventAggregator,
IMainViewportService mainViewportService,
ILoggerService logger)
: this(eventAggregator, mainViewportService, logger,
ReadInt("DetectorPipeline:AcquireQueueCapacity", 16, 1),
ReadInt("DetectorPipeline:ProcessQueueCapacity", 8, 1),
ReadInt("DetectorPipeline:ProcessEveryNFrames", 1, 1))
{
}
/// <summary>
/// Internal constructor for testing: accepts capacity and sampling values directly,
/// bypassing App.config reads.
/// </summary>
internal DetectorFramePipelineService(
IEventAggregator eventAggregator,
IMainViewportService mainViewportService,
ILoggerService logger,
int acquireQueueCapacity,
int processQueueCapacity,
int processEveryNFrames)
{
ArgumentNullException.ThrowIfNull(eventAggregator);
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
_logger = logger?.ForModule<DetectorFramePipelineService>() ?? throw new ArgumentNullException(nameof(logger));
AcquireQueueCapacity = ReadInt("DetectorPipeline:AcquireQueueCapacity", 16, 1);
ProcessQueueCapacity = ReadInt("DetectorPipeline:ProcessQueueCapacity", 8, 1);
ProcessEveryNFrames = ReadInt("DetectorPipeline:ProcessEveryNFrames", 1, 1);
AcquireQueueCapacity = Math.Max(1, acquireQueueCapacity);
ProcessQueueCapacity = Math.Max(1, processQueueCapacity);
ProcessEveryNFrames = Math.Max(1, processEveryNFrames);
eventAggregator.GetEvent<ImageCapturedEvent>()
.Subscribe(OnImageCaptured, ThreadOption.BackgroundThread);
@@ -7,6 +7,7 @@ namespace XplorePlane.Services.MainViewport
{
MainViewportSourceMode CurrentSourceMode { get; }
bool IsRealtimeDisplayEnabled { get; }
bool IsCncRunning { get; }
ImageSource CurrentDisplayImage { get; }
string CurrentDisplayInfo { get; }
ImageSource LatestDetectorImage { get; }
@@ -21,5 +22,11 @@ namespace XplorePlane.Services.MainViewport
void UpdateDetectorFrame(DetectorFrame frame);
void SetManualImage(ImageSource image, string filePath);
/// <summary>
/// 通知 MainViewportService 当前 CNC 运行状态。
/// CNC 开始运行时传入 true,结束时传入 false。
/// </summary>
void SetCncRunning(bool isRunning);
}
}
@@ -13,6 +13,7 @@ namespace XplorePlane.Services.MainViewport
private MainViewportSourceMode _currentSourceMode = MainViewportSourceMode.LiveDetector;
private bool _isRealtimeDisplayEnabled;
private bool _isCncRunning;
private ImageSource _currentDisplayImage;
private string _currentDisplayInfo = "等待探测器图像...";
private ImageSource _latestDetectorImage;
@@ -48,6 +49,17 @@ namespace XplorePlane.Services.MainViewport
}
}
public bool IsCncRunning
{
get
{
lock (_syncRoot)
{
return _isCncRunning;
}
}
}
public ImageSource CurrentDisplayImage
{
get
@@ -99,11 +111,21 @@ namespace XplorePlane.Services.MainViewport
bool changed;
lock (_syncRoot)
{
changed = _isRealtimeDisplayEnabled != isEnabled;
if (!isEnabled && _isCncRunning)
{
_logger.Warn("CNC 正在运行,忽略 SetRealtimeDisplayEnabled(false) 调用");
return;
}
changed = _isRealtimeDisplayEnabled != isEnabled
|| (isEnabled && _currentSourceMode == MainViewportSourceMode.ManualImage);
_isRealtimeDisplayEnabled = isEnabled;
if (_currentSourceMode == MainViewportSourceMode.LiveDetector && isEnabled)
if (isEnabled)
{
// 开启实时:无论当前是 ManualImage 还是 LiveDetector,都切回实时帧显示
_currentSourceMode = MainViewportSourceMode.LiveDetector;
ApplyLiveDetectorDisplay_NoLock();
}
}
@@ -173,6 +195,12 @@ namespace XplorePlane.Services.MainViewport
lock (_syncRoot)
{
if (_isCncRunning)
{
_logger.Warn("CNC 正在运行,忽略 SetManualImage 调用");
return;
}
_latestManualImage = image;
_latestManualInfo = $"手动加载图像 {fileName}";
_currentSourceMode = MainViewportSourceMode.ManualImage;
@@ -184,6 +212,23 @@ namespace XplorePlane.Services.MainViewport
RaiseStateChanged();
}
public void SetCncRunning(bool isRunning)
{
bool modeChanged = false;
lock (_syncRoot)
{
_isCncRunning = isRunning;
if (isRunning && _currentSourceMode == MainViewportSourceMode.ManualImage)
{
_currentSourceMode = MainViewportSourceMode.LiveDetector;
ApplyLiveDetectorDisplay_NoLock();
modeChanged = true;
}
}
_logger.Info("CNC 运行状态已更新:{IsRunning}", isRunning);
if (modeChanged) RaiseStateChanged();
}
private void ApplyLiveDetectorDisplay_NoLock()
{
_currentDisplayImage = _latestDetectorImage;
@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using XplorePlane.Models;
using XplorePlane.Services.Storage;
namespace XplorePlane.Services
{
@@ -29,6 +30,11 @@ namespace XplorePlane.Services
"XplorePlane", "Pipelines");
}
public PipelinePersistenceService(IImageProcessingService imageProcessingService, IXpDataPathService dataPathService)
: this(imageProcessingService, dataPathService?.ToolsPath)
{
}
public async Task SaveAsync(PipelineModel pipeline, string filePath)
{
if (pipeline == null) throw new ArgumentNullException(nameof(pipeline));
@@ -0,0 +1,23 @@
using System;
namespace XplorePlane.Services.Storage
{
public interface IXpDataPathService
{
string DefaultRootPath { get; }
string RootPath { get; }
string PlanPath { get; }
string ToolsPath { get; }
string DataPath { get; }
string ReportPath { get; }
string LegacyInspectionDataPath { get; }
void SaveRootPath(string rootPath);
}
}
@@ -0,0 +1,145 @@
using System;
using System.Configuration;
using System.IO;
using XP.Common.Logging.Interfaces;
namespace XplorePlane.Services.Storage
{
public class XpDataPathService : IXpDataPathService
{
internal const string RootPathSettingKey = "XpData:RootPath";
private const string DefaultRoot = @"D:\XPData";
private readonly ILoggerService _logger;
private readonly object _syncRoot = new();
private string _rootPath;
public XpDataPathService(ILoggerService logger)
{
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<XpDataPathService>();
_rootPath = LoadConfiguredRootPath();
EnsureManagedDirectories(_rootPath);
}
public string DefaultRootPath => DefaultRoot;
public string RootPath
{
get
{
lock (_syncRoot)
{
return _rootPath;
}
}
}
public string PlanPath => EnsureSubdirectory("Plan");
public string ToolsPath => EnsureSubdirectory("Tools");
public string DataPath => EnsureSubdirectory("Data");
public string ReportPath => EnsureSubdirectory("Report");
public string LegacyInspectionDataPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XplorePlane",
"InspectionResults");
public void SaveRootPath(string rootPath)
{
var normalizedRootPath = NormalizeRootPath(rootPath);
EnsureManagedDirectories(normalizedRootPath);
try
{
var config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
var appSettings = config.AppSettings.Settings;
if (appSettings[RootPathSettingKey] == null)
{
appSettings.Add(RootPathSettingKey, normalizedRootPath);
}
else
{
appSettings[RootPathSettingKey].Value = normalizedRootPath;
}
config.Save(ConfigurationSaveMode.Modified);
ConfigurationManager.RefreshSection("appSettings");
}
catch (ConfigurationErrorsException ex)
{
_logger.Error(ex, "保存 XP 数据根目录失败:配置文件写入异常");
throw new InvalidOperationException($"保存数据根目录失败:{ex.Message}", ex);
}
catch (UnauthorizedAccessException ex)
{
_logger.Error(ex, "保存 XP 数据根目录失败:无权写入配置文件");
throw new InvalidOperationException("保存数据根目录失败:没有配置文件写入权限。", ex);
}
lock (_syncRoot)
{
_rootPath = normalizedRootPath;
}
}
private string EnsureSubdirectory(string directoryName)
{
string rootPath;
lock (_syncRoot)
{
rootPath = _rootPath;
}
EnsureManagedDirectories(rootPath);
return Path.Combine(rootPath, directoryName);
}
private string LoadConfiguredRootPath()
{
try
{
return NormalizeRootPath(ConfigurationManager.AppSettings[RootPathSettingKey]);
}
catch (Exception ex)
{
_logger.Warn("读取 XP 数据根目录失败,回退默认目录:{Message}", ex.Message);
return NormalizeRootPath(null);
}
}
private static string NormalizeRootPath(string rootPath)
{
var candidate = string.IsNullOrWhiteSpace(rootPath) ? DefaultRoot : rootPath.Trim();
try
{
var normalized = Path.GetFullPath(candidate)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (!Path.IsPathRooted(normalized))
{
return DefaultRoot;
}
return normalized;
}
catch
{
return DefaultRoot;
}
}
private static void EnsureManagedDirectories(string rootPath)
{
Directory.CreateDirectory(rootPath);
Directory.CreateDirectory(Path.Combine(rootPath, "Plan"));
Directory.CreateDirectory(Path.Combine(rootPath, "Tools"));
Directory.CreateDirectory(Path.Combine(rootPath, "Data"));
Directory.CreateDirectory(Path.Combine(rootPath, "Report"));
}
}
}
@@ -16,6 +16,8 @@ using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Cnc;
using XplorePlane.Services;
using XplorePlane.Services.Storage;
namespace XplorePlane.ViewModels.Cnc
{
@@ -28,6 +30,8 @@ namespace XplorePlane.ViewModels.Cnc
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
private readonly ICncExecutionService _cncExecutionService;
private readonly IXpDataPathService _dataPathService;
private readonly IPipelinePersistenceService _pipelinePersistenceService;
private CncProgram _currentProgram;
private ObservableCollection<CncNodeViewModel> _nodes;
@@ -51,13 +55,17 @@ namespace XplorePlane.ViewModels.Cnc
IAppStateService appStateService,
IEventAggregator eventAggregator,
ILoggerService logger,
ICncExecutionService cncExecutionService)
ICncExecutionService cncExecutionService,
IXpDataPathService dataPathService,
IPipelinePersistenceService pipelinePersistenceService)
{
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
ArgumentNullException.ThrowIfNull(appStateService);
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<CncEditorViewModel>();
_cncExecutionService = cncExecutionService ?? throw new ArgumentNullException(nameof(cncExecutionService));
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
_pipelinePersistenceService = pipelinePersistenceService ?? throw new ArgumentNullException(nameof(pipelinePersistenceService));
_nodes = new ObservableCollection<CncNodeViewModel>();
_treeNodes = new ObservableCollection<CncNodeViewModel>();
@@ -331,7 +339,8 @@ namespace XplorePlane.ViewModels.Cnc
Title = "保存 CNC 程序",
Filter = "CNC 程序文件 (*.xp)|*.xp|所有文件 (*.*)|*.*",
DefaultExt = ".xp",
FileName = _currentProgram.Name
FileName = _currentProgram.Name,
InitialDirectory = GetPlanDirectory()
};
if (dlg.ShowDialog() != true)
@@ -355,7 +364,8 @@ namespace XplorePlane.ViewModels.Cnc
{
Title = "加载 CNC 程序",
Filter = "CNC 程序文件 (*.xp)|*.xp|所有文件 (*.*)|*.*",
DefaultExt = ".xp"
DefaultExt = ".xp",
InitialDirectory = GetPlanDirectory()
};
if (dlg.ShowDialog() != true)
@@ -407,7 +417,7 @@ namespace XplorePlane.ViewModels.Cnc
return;
var sb = new StringBuilder();
sb.AppendLine("Index,NodeType,Name,XM,YM,ZT,ZD,TiltD,Dist,Voltage_kV,Current_uA,Power_W,RayOn,DetectorConnected,FrameRate,Resolution,ImageFile,MarkerType,MarkerX,MarkerY,DialogTitle,DialogMessage,DelayMs,Pipeline");
sb.AppendLine("Index,NodeType,Name,SourceZ,DetectorZ,StageX,StageY,DetectorSwing,StageRotation,FixtureRotation,FOD,FDD,Magnification,Voltage_kV,Current_uA,Power_W,RayOn,DetectorConnected,FrameRate,Resolution,ImageFile,MarkerType,MarkerX,MarkerY,DialogTitle,DialogMessage,DelayMs,Pipeline");
var inv = CultureInfo.InvariantCulture;
@@ -415,16 +425,16 @@ namespace XplorePlane.ViewModels.Cnc
{
var row = node switch
{
ReferencePointNode rp => $"{rp.Index},{rp.NodeType},{Esc(rp.Name)},{rp.XM.ToString(inv)},{rp.YM.ToString(inv)},{rp.ZT.ToString(inv)},{rp.ZD.ToString(inv)},{rp.TiltD.ToString(inv)},{rp.Dist.ToString(inv)},{rp.Voltage.ToString(inv)},{rp.Current.ToString(inv)},,{rp.IsRayOn},,,,,,,,,,",
SaveNodeWithImageNode sni => $"{sni.Index},{sni.NodeType},{Esc(sni.Name)},{sni.MotionState.XM.ToString(inv)},{sni.MotionState.YM.ToString(inv)},{sni.MotionState.ZT.ToString(inv)},{sni.MotionState.ZD.ToString(inv)},{sni.MotionState.TiltD.ToString(inv)},{sni.MotionState.Dist.ToString(inv)},{sni.RaySourceState.Voltage.ToString(inv)},,{sni.RaySourceState.Power.ToString(inv)},{sni.RaySourceState.IsOn},{sni.DetectorState.IsConnected},{sni.DetectorState.FrameRate.ToString(inv)},{Esc(sni.DetectorState.Resolution)},{Esc(sni.ImageFileName)},,,,,,",
SaveNodeNode sn => $"{sn.Index},{sn.NodeType},{Esc(sn.Name)},{sn.MotionState.XM.ToString(inv)},{sn.MotionState.YM.ToString(inv)},{sn.MotionState.ZT.ToString(inv)},{sn.MotionState.ZD.ToString(inv)},{sn.MotionState.TiltD.ToString(inv)},{sn.MotionState.Dist.ToString(inv)},{sn.RaySourceState.Voltage.ToString(inv)},,{sn.RaySourceState.Power.ToString(inv)},{sn.RaySourceState.IsOn},{sn.DetectorState.IsConnected},{sn.DetectorState.FrameRate.ToString(inv)},{Esc(sn.DetectorState.Resolution)},,,,,,,",
SavePositionNode sp => $"{sp.Index},{sp.NodeType},{Esc(sp.Name)},{sp.MotionState.XM.ToString(inv)},{sp.MotionState.YM.ToString(inv)},{sp.MotionState.ZT.ToString(inv)},{sp.MotionState.ZD.ToString(inv)},{sp.MotionState.TiltD.ToString(inv)},{sp.MotionState.Dist.ToString(inv)},,,,,,,,,,,,,,",
InspectionModuleNode im => $"{im.Index},{im.NodeType},{Esc(im.Name)},,,,,,,,,,,,,,,,,,,{Esc(im.Pipeline?.Name ?? string.Empty)}",
InspectionMarkerNode mk => $"{mk.Index},{mk.NodeType},{Esc(mk.Name)},,,,,,,,,,,,,,{Esc(mk.MarkerType)},{mk.MarkerX.ToString(inv)},{mk.MarkerY.ToString(inv)},,,",
PauseDialogNode pd => $"{pd.Index},{pd.NodeType},{Esc(pd.Name)},,,,,,,,,,,,,,,,,{Esc(pd.DialogTitle)},{Esc(pd.DialogMessage)},,",
WaitDelayNode wd => $"{wd.Index},{wd.NodeType},{Esc(wd.Name)},,,,,,,,,,,,,,,,,,,{wd.DelayMilliseconds},",
CompleteProgramNode cp => $"{cp.Index},{cp.NodeType},{Esc(cp.Name)},,,,,,,,,,,,,,,,,,,,",
_ => $"{node.Index},{node.NodeType},{Esc(node.Name)},,,,,,,,,,,,,,,,,,,,"
ReferencePointNode rp => $"{rp.Index},{rp.NodeType},{Esc(rp.Name)},{rp.SourceZ.ToString(inv)},{rp.DetectorZ.ToString(inv)},{rp.StageX.ToString(inv)},{rp.StageY.ToString(inv)},{rp.DetectorSwing.ToString(inv)},{rp.StageRotation.ToString(inv)},{rp.FixtureRotation.ToString(inv)},{rp.FOD.ToString(inv)},{rp.FDD.ToString(inv)},{rp.Magnification.ToString(inv)},{rp.Voltage.ToString(inv)},{rp.Current.ToString(inv)},,{rp.IsRayOn},,,,,,,,,,",
SaveNodeWithImageNode sni => $"{sni.Index},{sni.NodeType},{Esc(sni.Name)},{sni.MotionState.SourceZ.ToString(inv)},{sni.MotionState.DetectorZ.ToString(inv)},{sni.MotionState.StageX.ToString(inv)},{sni.MotionState.StageY.ToString(inv)},{sni.MotionState.DetectorSwing.ToString(inv)},{sni.MotionState.StageRotation.ToString(inv)},{sni.MotionState.FixtureRotation.ToString(inv)},{sni.MotionState.FOD.ToString(inv)},{sni.MotionState.FDD.ToString(inv)},{sni.MotionState.Magnification.ToString(inv)},{sni.RaySourceState.Voltage.ToString(inv)},,{sni.RaySourceState.Power.ToString(inv)},{sni.RaySourceState.IsOn},{sni.DetectorState.IsConnected},{sni.DetectorState.FrameRate.ToString(inv)},{Esc(sni.DetectorState.Resolution)},{Esc(sni.ImageFileName)},,,,,,",
SaveNodeNode sn => $"{sn.Index},{sn.NodeType},{Esc(sn.Name)},{sn.MotionState.SourceZ.ToString(inv)},{sn.MotionState.DetectorZ.ToString(inv)},{sn.MotionState.StageX.ToString(inv)},{sn.MotionState.StageY.ToString(inv)},{sn.MotionState.DetectorSwing.ToString(inv)},{sn.MotionState.StageRotation.ToString(inv)},{sn.MotionState.FixtureRotation.ToString(inv)},{sn.MotionState.FOD.ToString(inv)},{sn.MotionState.FDD.ToString(inv)},{sn.MotionState.Magnification.ToString(inv)},{sn.RaySourceState.Voltage.ToString(inv)},,{sn.RaySourceState.Power.ToString(inv)},{sn.RaySourceState.IsOn},{sn.DetectorState.IsConnected},{sn.DetectorState.FrameRate.ToString(inv)},{Esc(sn.DetectorState.Resolution)},,,,,,,",
SavePositionNode sp => $"{sp.Index},{sp.NodeType},{Esc(sp.Name)},{sp.MotionState.SourceZ.ToString(inv)},{sp.MotionState.DetectorZ.ToString(inv)},{sp.MotionState.StageX.ToString(inv)},{sp.MotionState.StageY.ToString(inv)},{sp.MotionState.DetectorSwing.ToString(inv)},{sp.MotionState.StageRotation.ToString(inv)},{sp.MotionState.FixtureRotation.ToString(inv)},{sp.MotionState.FOD.ToString(inv)},{sp.MotionState.FDD.ToString(inv)},{sp.MotionState.Magnification.ToString(inv)},,,,,,,,,,,,,,",
InspectionModuleNode im => $"{im.Index},{im.NodeType},{Esc(im.Name)},,,,,,,,,,,,,,,,,,,,,,{Esc(im.Pipeline?.Name ?? string.Empty)}",
InspectionMarkerNode mk => $"{mk.Index},{mk.NodeType},{Esc(mk.Name)},,,,,,,,,,,,,,,,,{Esc(mk.MarkerType)},{mk.MarkerX.ToString(inv)},{mk.MarkerY.ToString(inv)},,,",
PauseDialogNode pd => $"{pd.Index},{pd.NodeType},{Esc(pd.Name)},,,,,,,,,,,,,,,,,,,{Esc(pd.DialogTitle)},{Esc(pd.DialogMessage)},,",
WaitDelayNode wd => $"{wd.Index},{wd.NodeType},{Esc(wd.Name)},,,,,,,,,,,,,,,,,,,,,{wd.DelayMilliseconds},",
CompleteProgramNode cp => $"{cp.Index},{cp.NodeType},{Esc(cp.Name)},,,,,,,,,,,,,,,,,,,,,,",
_ => $"{node.Index},{node.NodeType},{Esc(node.Name)},,,,,,,,,,,,,,,,,,,,,,"
};
sb.AppendLine(row);
@@ -438,6 +448,64 @@ namespace XplorePlane.ViewModels.Cnc
}
}
public async Task InsertInspectionModuleFromPipelineFileAsync(string filePath)
{
if (IsRunning)
return;
if (string.IsNullOrWhiteSpace(filePath))
throw new ArgumentException("检测模块文件路径不能为空。", nameof(filePath));
if (!File.Exists(filePath))
throw new FileNotFoundException("检测模块文件不存在。", filePath);
if (_currentProgram == null)
{
ExecuteNewProgram();
}
var pipeline = await _pipelinePersistenceService.LoadAsync(filePath);
try
{
var node = _cncProgramService.CreateNode(CncNodeType.InspectionModule);
if (node is not InspectionModuleNode inspectionModuleNode)
throw new InvalidOperationException("无法创建检测模块节点。");
var pipelineName = string.IsNullOrWhiteSpace(pipeline.Name)
? Path.GetFileNameWithoutExtension(filePath)
: pipeline.Name;
pipeline.Name = pipelineName;
var configuredNode = inspectionModuleNode with
{
Pipeline = pipeline,
Name = pipelineName
};
int afterIndex = ResolveInsertAfterIndex(CncNodeType.InspectionModule);
_currentProgram = _cncProgramService.InsertNode(_currentProgram, afterIndex, configuredNode);
_preferredSelectedNodeId = configuredNode.Id;
ClearPendingInsertAnchor();
OnProgramEdited();
StatusMessage = $"已插入检测模块:{pipelineName}";
_logger.Info("Inserted built-in inspection module from file: {FilePath}", filePath);
}
catch (InvalidOperationException ex)
{
_logger.Warn("Built-in inspection module insertion blocked: {Message}", ex.Message);
throw;
}
}
private string GetPlanDirectory()
{
var directory = _dataPathService.PlanPath;
Directory.CreateDirectory(directory);
return directory;
}
private static string Esc(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
@@ -455,6 +523,7 @@ namespace XplorePlane.ViewModels.Cnc
private async Task ExecuteRunAsync()
{
_cts = new CancellationTokenSource();
ResetAllNodeStates();
IsRunning = true;
HasExecutionError = false;
ExecutionError = null;
@@ -499,6 +568,7 @@ namespace XplorePlane.ViewModels.Cnc
if (nodeVm != null)
{
nodeVm.ExecutionState = progress.State;
nodeVm.ExecutionProgressPercent = progress.ProgressPercent ?? (progress.State == NodeExecutionState.Succeeded ? 100d : 0d);
if (progress.State == NodeExecutionState.Running)
StatusMessage = $"正在执行节点:{nodeVm.Name}{nodeVm.Index + 1}/{_currentProgram?.Nodes?.Count ?? 0}";
else if (progress.State == NodeExecutionState.Succeeded)
@@ -519,7 +589,10 @@ namespace XplorePlane.ViewModels.Cnc
private void ResetAllNodeStates()
{
foreach (var node in Nodes)
{
node.ExecutionState = NodeExecutionState.Idle;
node.ExecutionProgressPercent = 0;
}
}
private void RaiseEditCommandsCanExecuteChanged()
@@ -1,4 +1,4 @@
using Microsoft.Win32;
using Microsoft.Win32;
using Prism.Commands;
using Prism.Mvvm;
using System;
@@ -37,6 +37,7 @@ namespace XplorePlane.ViewModels.Cnc
private string _statusMessage = "请选择检测模块以编辑其流水线。";
private string _pipelineFileDisplayName = "未命名模块.xpm";
private string _currentFilePath;
private PipelineNodeViewModel _executionEndNode;
private bool _isSynchronizing;
private CancellationTokenSource _debounceCts;
@@ -65,6 +66,8 @@ namespace XplorePlane.ViewModels.Cnc
RemoveOperatorCommand = new DelegateCommand<PipelineNodeViewModel>(RemoveOperator);
ReorderOperatorCommand = new DelegateCommand<PipelineReorderArgs>(ReorderOperator);
ToggleOperatorEnabledCommand = new DelegateCommand<PipelineNodeViewModel>(ToggleOperatorEnabled);
ExecuteToNodeCommand = new DelegateCommand<PipelineNodeViewModel>(ExecuteToNode);
ClearExecutionRangeCommand = new DelegateCommand(ClearExecutionRange);
MoveNodeUpCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeDown);
NewPipelineCommand = new DelegateCommand(NewPipeline);
@@ -98,6 +101,16 @@ namespace XplorePlane.ViewModels.Cnc
private set => SetProperty(ref _pipelineFileDisplayName, value);
}
public PipelineNodeViewModel ExecutionEndNode
{
get => _executionEndNode;
private set
{
if (SetProperty(ref _executionEndNode, value))
UpdateExecutionRangeState();
}
}
public bool HasActiveModule => _activeModuleNode?.IsInspectionModule == true;
public Visibility EditorVisibility => HasActiveModule ? Visibility.Visible : Visibility.Collapsed;
@@ -112,6 +125,10 @@ namespace XplorePlane.ViewModels.Cnc
public ICommand ToggleOperatorEnabledCommand { get; }
public ICommand ExecuteToNodeCommand { get; }
public ICommand ClearExecutionRangeCommand { get; }
public ICommand MoveNodeUpCommand { get; }
public ICommand MoveNodeDownCommand { get; }
@@ -179,6 +196,7 @@ namespace XplorePlane.ViewModels.Cnc
LoadNodeParameters(node, null);
PipelineNodes.Add(node);
SelectedNode = node;
UpdateExecutionRangeState();
PersistActiveModule($"已添加算子:{displayName}");
}
@@ -190,6 +208,10 @@ namespace XplorePlane.ViewModels.Cnc
var removedIndex = PipelineNodes.IndexOf(node);
PipelineNodes.Remove(node);
RenumberNodes();
if (ReferenceEquals(ExecutionEndNode, node))
ExecutionEndNode = null;
else
UpdateExecutionRangeState();
SelectNeighborAfterRemoval(removedIndex);
PersistActiveModule($"已移除算子:{node.DisplayName}");
@@ -206,6 +228,7 @@ namespace XplorePlane.ViewModels.Cnc
PipelineNodes.Move(index, index - 1);
RenumberNodes();
UpdateExecutionRangeState();
PersistActiveModule($"已上移算子:{node.DisplayName}");
}
@@ -225,6 +248,7 @@ namespace XplorePlane.ViewModels.Cnc
var node = PipelineNodes[oldIndex];
PipelineNodes.Move(oldIndex, newIndex);
RenumberNodes();
UpdateExecutionRangeState();
SelectedNode = node;
PersistActiveModule($"已调整算子顺序:{node.DisplayName}");
}
@@ -240,6 +264,7 @@ namespace XplorePlane.ViewModels.Cnc
PipelineNodes.Move(index, index + 1);
RenumberNodes();
UpdateExecutionRangeState();
PersistActiveModule($"已下移算子:{node.DisplayName}");
}
@@ -255,6 +280,25 @@ namespace XplorePlane.ViewModels.Cnc
: $"已停用算子:{node.DisplayName}");
}
private void ExecuteToNode(PipelineNodeViewModel node)
{
if (!HasActiveModule || node == null || !PipelineNodes.Contains(node))
return;
SelectedNode = node;
ExecutionEndNode = node;
PersistActiveModule($"已设置执行截止点:{node.DisplayName}");
}
private void ClearExecutionRange()
{
if (!HasActiveModule || ExecutionEndNode == null)
return;
ExecutionEndNode = null;
PersistActiveModule("已切换为执行全部节点");
}
private void NewPipeline()
{
if (!HasActiveModule)
@@ -262,6 +306,7 @@ namespace XplorePlane.ViewModels.Cnc
PipelineNodes.Clear();
SelectedNode = null;
ExecutionEndNode = null;
_currentFilePath = null;
PipelineFileDisplayName = GetActivePipelineFileDisplayName();
PersistActiveModule("已为当前检测模块新建空流水线。");
@@ -326,6 +371,7 @@ namespace XplorePlane.ViewModels.Cnc
{
PipelineNodes.Clear();
SelectedNode = null;
ExecutionEndNode = null;
var orderedNodes = (pipeline?.Nodes ?? new List<PipelineNodeModel>())
.OrderBy(node => node.Order)
@@ -346,6 +392,7 @@ namespace XplorePlane.ViewModels.Cnc
}
SelectedNode = PipelineNodes.FirstOrDefault();
UpdateExecutionRangeState();
if (string.IsNullOrEmpty(_currentFilePath))
PipelineFileDisplayName = GetActivePipelineFileDisplayName();
StatusMessage = HasActiveModule
@@ -423,7 +470,7 @@ namespace XplorePlane.ViewModels.Cnc
try
{
_logger.Info("[图像链路][CNC] ExecutePreviewAsync:开始执行,节点数={Count}", PipelineNodes.Count);
var result = await _executionService.ExecutePipelineAsync(PipelineNodes, sourceImage, null, token);
var result = await _executionService.ExecutePipelineAsync(GetNodesInExecutionScope(), sourceImage, null, token);
_logger.Info("[图像链路][CNC] ExecutePreviewAsync:执行完成,推送结果图像");
_mainViewportService.SetManualImage(result, string.Empty);
_eventAggregator?.GetEvent<PipelinePreviewUpdatedEvent>()
@@ -459,6 +506,15 @@ namespace XplorePlane.ViewModels.Cnc
};
}
private IEnumerable<PipelineNodeViewModel> GetNodesInExecutionScope()
{
var orderedNodes = PipelineNodes.OrderBy(node => node.Order);
if (ExecutionEndNode == null)
return orderedNodes;
return orderedNodes.Where(node => node.Order <= ExecutionEndNode.Order);
}
private string GetActivePipelineName()
{
if (!HasActiveModule)
@@ -492,6 +548,19 @@ namespace XplorePlane.ViewModels.Cnc
}
}
private void UpdateExecutionRangeState()
{
if (_executionEndNode != null && !PipelineNodes.Contains(_executionEndNode))
_executionEndNode = null;
var endOrder = _executionEndNode?.Order;
foreach (var node in PipelineNodes)
{
node.IsExecutionEndNode = endOrder.HasValue && node.Order == endOrder.Value;
node.IsSkippedByExecutionRange = endOrder.HasValue && node.Order > endOrder.Value;
}
}
private void SelectNeighborAfterRemoval(int removedIndex)
{
if (PipelineNodes.Count == 0)
+148 -60
View File
@@ -16,6 +16,7 @@ namespace XplorePlane.ViewModels.Cnc
private string _icon;
private bool _isExpanded = true;
private NodeExecutionState _executionState = NodeExecutionState.Idle;
private double _executionProgressPercent;
/// <summary>执行后缓存的流水线输出图像(仅 InspectionModuleNode</summary>
public BitmapSource ResultImage { get; set; }
@@ -72,6 +73,7 @@ namespace XplorePlane.ViewModels.Cnc
RaisePropertyChanged(nameof(IsRunningNode));
RaisePropertyChanged(nameof(IsSucceededNode));
RaisePropertyChanged(nameof(IsFailedNode));
RaisePropertyChanged(nameof(IsDelayProgressVisible));
}
}
}
@@ -79,6 +81,21 @@ namespace XplorePlane.ViewModels.Cnc
public bool IsRunningNode => ExecutionState == NodeExecutionState.Running;
public bool IsSucceededNode => ExecutionState == NodeExecutionState.Succeeded;
public bool IsFailedNode => ExecutionState == NodeExecutionState.Failed;
public bool IsDelayProgressVisible => IsWaitDelay && IsRunningNode;
public double ExecutionProgressPercent
{
get => _executionProgressPercent;
set
{
if (SetProperty(ref _executionProgressPercent, Math.Clamp(value, 0d, 100d)))
{
RaisePropertyChanged(nameof(ExecutionProgressText));
}
}
}
public string ExecutionProgressText => $"{ExecutionProgressPercent:0}%";
public bool IsReferencePoint => _model is ReferencePointNode;
public bool IsSaveNode => _model is SaveNodeNode;
@@ -99,82 +116,134 @@ namespace XplorePlane.ViewModels.Cnc
_ => string.Empty
};
public double XM
public double StageX
{
get => _model switch
{
ReferencePointNode rp => rp.XM,
SaveNodeNode sn => sn.MotionState.XM,
SaveNodeWithImageNode sni => sni.MotionState.XM,
SavePositionNode sp => sp.MotionState.XM,
ReferencePointNode rp => rp.StageX,
SaveNodeNode sn => sn.MotionState.StageX,
SaveNodeWithImageNode sni => sni.MotionState.StageX,
SavePositionNode sp => sp.MotionState.StageX,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.XM);
set => UpdateMotion(value, MotionAxis.StageX);
}
public double YM
public double StageY
{
get => _model switch
{
ReferencePointNode rp => rp.YM,
SaveNodeNode sn => sn.MotionState.YM,
SaveNodeWithImageNode sni => sni.MotionState.YM,
SavePositionNode sp => sp.MotionState.YM,
ReferencePointNode rp => rp.StageY,
SaveNodeNode sn => sn.MotionState.StageY,
SaveNodeWithImageNode sni => sni.MotionState.StageY,
SavePositionNode sp => sp.MotionState.StageY,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.YM);
set => UpdateMotion(value, MotionAxis.StageY);
}
public double ZT
public double SourceZ
{
get => _model switch
{
ReferencePointNode rp => rp.ZT,
SaveNodeNode sn => sn.MotionState.ZT,
SaveNodeWithImageNode sni => sni.MotionState.ZT,
SavePositionNode sp => sp.MotionState.ZT,
ReferencePointNode rp => rp.SourceZ,
SaveNodeNode sn => sn.MotionState.SourceZ,
SaveNodeWithImageNode sni => sni.MotionState.SourceZ,
SavePositionNode sp => sp.MotionState.SourceZ,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.ZT);
set => UpdateMotion(value, MotionAxis.SourceZ);
}
public double ZD
public double DetectorZ
{
get => _model switch
{
ReferencePointNode rp => rp.ZD,
SaveNodeNode sn => sn.MotionState.ZD,
SaveNodeWithImageNode sni => sni.MotionState.ZD,
SavePositionNode sp => sp.MotionState.ZD,
ReferencePointNode rp => rp.DetectorZ,
SaveNodeNode sn => sn.MotionState.DetectorZ,
SaveNodeWithImageNode sni => sni.MotionState.DetectorZ,
SavePositionNode sp => sp.MotionState.DetectorZ,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.ZD);
set => UpdateMotion(value, MotionAxis.DetectorZ);
}
public double TiltD
public double DetectorSwing
{
get => _model switch
{
ReferencePointNode rp => rp.TiltD,
SaveNodeNode sn => sn.MotionState.TiltD,
SaveNodeWithImageNode sni => sni.MotionState.TiltD,
SavePositionNode sp => sp.MotionState.TiltD,
ReferencePointNode rp => rp.DetectorSwing,
SaveNodeNode sn => sn.MotionState.DetectorSwing,
SaveNodeWithImageNode sni => sni.MotionState.DetectorSwing,
SavePositionNode sp => sp.MotionState.DetectorSwing,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.TiltD);
set => UpdateMotion(value, MotionAxis.DetectorSwing);
}
public double Dist
public double StageRotation
{
get => _model switch
{
ReferencePointNode rp => rp.Dist,
SaveNodeNode sn => sn.MotionState.Dist,
SaveNodeWithImageNode sni => sni.MotionState.Dist,
SavePositionNode sp => sp.MotionState.Dist,
ReferencePointNode rp => rp.StageRotation,
SaveNodeNode sn => sn.MotionState.StageRotation,
SaveNodeWithImageNode sni => sni.MotionState.StageRotation,
SavePositionNode sp => sp.MotionState.StageRotation,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.Dist);
set => UpdateMotion(value, MotionAxis.StageRotation);
}
public double FixtureRotation
{
get => _model switch
{
ReferencePointNode rp => rp.FixtureRotation,
SaveNodeNode sn => sn.MotionState.FixtureRotation,
SaveNodeWithImageNode sni => sni.MotionState.FixtureRotation,
SavePositionNode sp => sp.MotionState.FixtureRotation,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.FixtureRotation);
}
public double FOD
{
get => _model switch
{
ReferencePointNode rp => rp.FOD,
SaveNodeNode sn => sn.MotionState.FOD,
SaveNodeWithImageNode sni => sni.MotionState.FOD,
SavePositionNode sp => sp.MotionState.FOD,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.FOD);
}
public double FDD
{
get => _model switch
{
ReferencePointNode rp => rp.FDD,
SaveNodeNode sn => sn.MotionState.FDD,
SaveNodeWithImageNode sni => sni.MotionState.FDD,
SavePositionNode sp => sp.MotionState.FDD,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.FDD);
}
public double Magnification
{
get => _model switch
{
ReferencePointNode rp => rp.Magnification,
SaveNodeNode sn => sn.MotionState.Magnification,
SaveNodeWithImageNode sni => sni.MotionState.Magnification,
SavePositionNode sp => sp.MotionState.Magnification,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.Magnification);
}
public bool IsRayOn
@@ -409,12 +478,16 @@ namespace XplorePlane.ViewModels.Cnc
case ReferencePointNode rp:
UpdateModel(axis switch
{
MotionAxis.XM => rp with { XM = value },
MotionAxis.YM => rp with { YM = value },
MotionAxis.ZT => rp with { ZT = value },
MotionAxis.ZD => rp with { ZD = value },
MotionAxis.TiltD => rp with { TiltD = value },
MotionAxis.Dist => rp with { Dist = value },
MotionAxis.StageX => rp with { StageX = value },
MotionAxis.StageY => rp with { StageY = value },
MotionAxis.SourceZ => rp with { SourceZ = value },
MotionAxis.DetectorZ => rp with { DetectorZ = value },
MotionAxis.DetectorSwing => rp with { DetectorSwing = value },
MotionAxis.StageRotation => rp with { StageRotation = value },
MotionAxis.FixtureRotation => rp with { FixtureRotation = value },
MotionAxis.FOD => rp with { FOD = value },
MotionAxis.FDD => rp with { FDD = value },
MotionAxis.Magnification => rp with { Magnification = value },
_ => rp
});
break;
@@ -502,12 +575,16 @@ namespace XplorePlane.ViewModels.Cnc
{
return axis switch
{
MotionAxis.XM => state with { XM = value },
MotionAxis.YM => state with { YM = value },
MotionAxis.ZT => state with { ZT = value },
MotionAxis.ZD => state with { ZD = value },
MotionAxis.TiltD => state with { TiltD = value },
MotionAxis.Dist => state with { Dist = value },
MotionAxis.StageX => state with { StageX = value },
MotionAxis.StageY => state with { StageY = value },
MotionAxis.SourceZ => state with { SourceZ = value },
MotionAxis.DetectorZ => state with { DetectorZ = value },
MotionAxis.DetectorSwing => state with { DetectorSwing = value },
MotionAxis.StageRotation => state with { StageRotation = value },
MotionAxis.FixtureRotation => state with { FixtureRotation = value },
MotionAxis.FOD => state with { FOD = value },
MotionAxis.FDD => state with { FDD = value },
MotionAxis.Magnification => state with { Magnification = value },
_ => state
};
}
@@ -540,12 +617,16 @@ namespace XplorePlane.ViewModels.Cnc
RaisePropertyChanged(nameof(IsPositionChild));
RaisePropertyChanged(nameof(IsMotionSnapshotNode));
RaisePropertyChanged(nameof(RelationTag));
RaisePropertyChanged(nameof(XM));
RaisePropertyChanged(nameof(YM));
RaisePropertyChanged(nameof(ZT));
RaisePropertyChanged(nameof(ZD));
RaisePropertyChanged(nameof(TiltD));
RaisePropertyChanged(nameof(Dist));
RaisePropertyChanged(nameof(StageX));
RaisePropertyChanged(nameof(StageY));
RaisePropertyChanged(nameof(SourceZ));
RaisePropertyChanged(nameof(DetectorZ));
RaisePropertyChanged(nameof(DetectorSwing));
RaisePropertyChanged(nameof(StageRotation));
RaisePropertyChanged(nameof(FixtureRotation));
RaisePropertyChanged(nameof(FOD));
RaisePropertyChanged(nameof(FDD));
RaisePropertyChanged(nameof(Magnification));
RaisePropertyChanged(nameof(IsRayOn));
RaisePropertyChanged(nameof(Voltage));
RaisePropertyChanged(nameof(Current));
@@ -567,16 +648,23 @@ namespace XplorePlane.ViewModels.Cnc
RaisePropertyChanged(nameof(IsRunningNode));
RaisePropertyChanged(nameof(IsSucceededNode));
RaisePropertyChanged(nameof(IsFailedNode));
RaisePropertyChanged(nameof(IsDelayProgressVisible));
RaisePropertyChanged(nameof(ExecutionProgressPercent));
RaisePropertyChanged(nameof(ExecutionProgressText));
}
private enum MotionAxis
{
XM,
YM,
ZT,
ZD,
TiltD,
Dist
StageX,
StageY,
SourceZ,
DetectorZ,
DetectorSwing,
StageRotation,
FixtureRotation,
FOD,
FDD,
Magnification
}
}
}
@@ -23,6 +23,10 @@ namespace XplorePlane.ViewModels
ICommand ToggleOperatorEnabledCommand { get; }
ICommand ExecuteToNodeCommand { get; }
ICommand ClearExecutionRangeCommand { get; }
ICommand MoveNodeUpCommand { get; }
ICommand MoveNodeDownCommand { get; }
@@ -36,6 +36,7 @@ namespace XplorePlane.ViewModels
{
private readonly IImageProcessingService _imageProcessingService;
private string _searchText = string.Empty;
private OperatorGroupViewModel _selectedGroup;
// UI 元数据(分类 + 图标)由 ProcessorUiMetadata 统一提供,保持工具箱与流水线图标一致
@@ -52,6 +53,12 @@ namespace XplorePlane.ViewModels
public ObservableCollection<OperatorDescriptor> FilteredOperators { get; }
public ObservableCollection<OperatorGroupViewModel> FilteredGroups { get; }
public OperatorGroupViewModel SelectedGroup
{
get => _selectedGroup;
set => SetProperty(ref _selectedGroup, value);
}
public string SearchText
{
get => _searchText;
@@ -78,6 +85,7 @@ namespace XplorePlane.ViewModels
{
FilteredOperators.Clear();
FilteredGroups.Clear();
SelectedGroup = null;
var filtered = string.IsNullOrWhiteSpace(SearchText)
? AvailableOperators
@@ -101,6 +109,8 @@ namespace XplorePlane.ViewModels
Operators = new ObservableCollection<OperatorDescriptor>(group)
});
}
SelectedGroup = FilteredGroups.FirstOrDefault();
}
private static int GetCategoryOrder(string category) => category switch
@@ -1,4 +1,4 @@
using Microsoft.Win32;
using Microsoft.Win32;
using Prism.Events;
using Prism.Commands;
using Prism.Mvvm;
@@ -14,6 +14,7 @@ using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Storage;
namespace XplorePlane.ViewModels
{
@@ -28,17 +29,19 @@ namespace XplorePlane.ViewModels
private readonly IPipelinePersistenceService _persistenceService;
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
private readonly IXpDataPathService _dataPathService;
private PipelineNodeViewModel _selectedNode;
private BitmapSource _sourceImage;
private BitmapSource _previewImage;
private string _pipelineName = "新建流水线";
private string _pipelineName = "新建模块";
private string _selectedDevice = string.Empty;
private bool _isExecuting;
private bool _isStatusError;
private string _statusMessage = string.Empty;
private string _pipelineFileDisplayName = DefaultPipelineFileDisplayName;
private string _currentFilePath;
private PipelineNodeViewModel _executionEndNode;
private CancellationTokenSource _executionCts;
private CancellationTokenSource _debounceCts;
@@ -48,13 +51,15 @@ namespace XplorePlane.ViewModels
IPipelineExecutionService executionService,
IPipelinePersistenceService persistenceService,
IEventAggregator eventAggregator,
ILoggerService logger)
ILoggerService logger,
IXpDataPathService dataPathService)
{
_imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService));
_executionService = executionService ?? throw new ArgumentNullException(nameof(executionService));
_persistenceService = persistenceService ?? throw new ArgumentNullException(nameof(persistenceService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_logger = logger?.ForModule<PipelineEditorViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
PipelineNodes = new ObservableCollection<PipelineNodeViewModel>();
AvailableDevices = new ObservableCollection<string>();
@@ -64,6 +69,8 @@ namespace XplorePlane.ViewModels
ReorderOperatorCommand = new DelegateCommand<PipelineReorderArgs>(ReorderOperator);
ToggleOperatorEnabledCommand = new DelegateCommand<PipelineNodeViewModel>(ToggleOperatorEnabled);
ExecutePipelineCommand = new DelegateCommand(async () => await ExecutePipelineAsync(), () => !IsExecuting && SourceImage != null);
ExecuteToNodeCommand = new DelegateCommand<PipelineNodeViewModel>(async node => await ExecuteToNodeAsync(node), CanExecuteToNode);
ClearExecutionRangeCommand = new DelegateCommand(async () => await ClearExecutionRangeAsync(), CanClearExecutionRange);
CancelExecutionCommand = new DelegateCommand(CancelExecution, () => IsExecuting);
NewPipelineCommand = new DelegateCommand(NewPipeline);
SavePipelineCommand = new DelegateCommand(async () => await SavePipelineAsync());
@@ -71,7 +78,6 @@ namespace XplorePlane.ViewModels
DeletePipelineCommand = new DelegateCommand(async () => await DeletePipelineAsync());
LoadPipelineCommand = new DelegateCommand(async () => await LoadPipelineAsync());
LoadImageCommand = new DelegateCommand(LoadImage);
OpenToolboxCommand = new DelegateCommand(OpenToolbox);
MoveNodeUpCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeDown);
@@ -163,6 +169,20 @@ namespace XplorePlane.ViewModels
private set => SetProperty(ref _pipelineFileDisplayName, value);
}
public PipelineNodeViewModel ExecutionEndNode
{
get => _executionEndNode;
private set
{
if (SetProperty(ref _executionEndNode, value))
{
UpdateExecutionRangeState();
ExecuteToNodeCommand.RaiseCanExecuteChanged();
ClearExecutionRangeCommand.RaiseCanExecuteChanged();
}
}
}
// ── Commands ──────────────────────────────────────────────────
public DelegateCommand<string> AddOperatorCommand { get; }
@@ -170,6 +190,8 @@ namespace XplorePlane.ViewModels
public DelegateCommand<PipelineReorderArgs> ReorderOperatorCommand { get; }
public DelegateCommand<PipelineNodeViewModel> ToggleOperatorEnabledCommand { get; }
public DelegateCommand ExecutePipelineCommand { get; }
public DelegateCommand<PipelineNodeViewModel> ExecuteToNodeCommand { get; }
public DelegateCommand ClearExecutionRangeCommand { get; }
public DelegateCommand CancelExecutionCommand { get; }
public DelegateCommand NewPipelineCommand { get; }
public DelegateCommand SavePipelineCommand { get; }
@@ -178,8 +200,6 @@ namespace XplorePlane.ViewModels
public DelegateCommand LoadPipelineCommand { get; }
public DelegateCommand LoadImageCommand { get; }
public DelegateCommand OpenToolboxCommand { get; }
public DelegateCommand<PipelineNodeViewModel> MoveNodeUpCommand { get; }
public DelegateCommand<PipelineNodeViewModel> MoveNodeDownCommand { get; }
@@ -187,6 +207,8 @@ namespace XplorePlane.ViewModels
ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand;
ICommand IPipelineEditorHostViewModel.ReorderOperatorCommand => ReorderOperatorCommand;
ICommand IPipelineEditorHostViewModel.ToggleOperatorEnabledCommand => ToggleOperatorEnabledCommand;
ICommand IPipelineEditorHostViewModel.ExecuteToNodeCommand => ExecuteToNodeCommand;
ICommand IPipelineEditorHostViewModel.ClearExecutionRangeCommand => ClearExecutionRangeCommand;
ICommand IPipelineEditorHostViewModel.MoveNodeUpCommand => MoveNodeUpCommand;
ICommand IPipelineEditorHostViewModel.MoveNodeDownCommand => MoveNodeDownCommand;
ICommand IPipelineEditorHostViewModel.NewPipelineCommand => NewPipelineCommand;
@@ -237,6 +259,7 @@ namespace XplorePlane.ViewModels
LoadNodeParameters(node);
PipelineNodes.Add(node);
SelectedNode = node;
UpdateExecutionRangeState();
_logger.Info("节点已添加到 PipelineNodes{Key} ({DisplayName}),当前节点数={Count}",
operatorKey, displayName, PipelineNodes.Count);
SetInfoStatus($"已添加算子:{displayName}");
@@ -250,6 +273,10 @@ namespace XplorePlane.ViewModels
var removedIndex = PipelineNodes.IndexOf(node);
PipelineNodes.Remove(node);
RenumberNodes();
if (ReferenceEquals(ExecutionEndNode, node))
ExecutionEndNode = null;
else
UpdateExecutionRangeState();
SelectNeighborAfterRemoval(removedIndex);
SetInfoStatus($"已移除算子:{node.DisplayName}");
@@ -263,6 +290,7 @@ namespace XplorePlane.ViewModels
if (index <= 0) return;
PipelineNodes.Move(index, index - 1);
RenumberNodes();
UpdateExecutionRangeState();
TriggerDebouncedExecution();
}
@@ -273,6 +301,7 @@ namespace XplorePlane.ViewModels
if (index < 0 || index >= PipelineNodes.Count - 1) return;
PipelineNodes.Move(index, index + 1);
RenumberNodes();
UpdateExecutionRangeState();
TriggerDebouncedExecution();
}
@@ -290,6 +319,7 @@ namespace XplorePlane.ViewModels
PipelineNodes.RemoveAt(oldIndex);
PipelineNodes.Insert(newIndex, node);
RenumberNodes();
UpdateExecutionRangeState();
SelectedNode = node;
SetInfoStatus($"已调整算子顺序:{node.DisplayName}");
TriggerDebouncedExecution();
@@ -307,6 +337,34 @@ namespace XplorePlane.ViewModels
TriggerDebouncedExecution();
}
private bool CanExecuteToNode(PipelineNodeViewModel node) =>
node != null && PipelineNodes.Contains(node) && !IsExecuting && SourceImage != null;
private async Task ExecuteToNodeAsync(PipelineNodeViewModel node)
{
if (!CanExecuteToNode(node))
return;
SelectedNode = node;
ExecutionEndNode = node;
await ExecutePipelineAsync();
}
private bool CanClearExecutionRange() =>
ExecutionEndNode != null && !IsExecuting;
private async Task ClearExecutionRangeAsync()
{
if (ExecutionEndNode == null)
return;
ExecutionEndNode = null;
SetInfoStatus("已切换为执行全部节点");
if (SourceImage != null)
await ExecutePipelineAsync();
}
private void RenumberNodes()
{
for (int i = 0; i < PipelineNodes.Count; i++)
@@ -370,10 +428,15 @@ namespace XplorePlane.ViewModels
_executionCts?.Cancel();
_executionCts = new CancellationTokenSource();
var token = _executionCts.Token;
var executionNodes = GetNodesInExecutionScope()
.Where(n => n.IsEnabled)
.OrderBy(n => n.Order)
.ToList();
IsExecuting = true;
SetInfoStatus("正在执行流水线...");
_logger.Info("[图像链路] ExecutePipelineAsync:开始执行,节点数={Count}", PipelineNodes.Count);
SetInfoStatus(BuildExecutionStartMessage(executionNodes.Count));
_logger.Info("[图像链路] ExecutePipelineAsync:开始执行,范围节点数={Count},截止节点={Node}",
executionNodes.Count, ExecutionEndNode?.DisplayName ?? "<all>");
try
{
@@ -381,10 +444,10 @@ namespace XplorePlane.ViewModels
SetInfoStatus($"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})"));
var result = await _executionService.ExecutePipelineAsync(
PipelineNodes, SourceImage, progress, token);
executionNodes, SourceImage, progress, token);
PreviewImage = result;
SetInfoStatus("流水线执行完成");
SetInfoStatus(BuildExecutionCompletedMessage(executionNodes.Count));
_logger.Info("[图像链路] ExecutePipelineAsync:执行完成,准备发布 PipelinePreviewUpdatedEvent");
PublishPipelinePreviewUpdated(result, StatusMessage);
}
@@ -411,7 +474,7 @@ namespace XplorePlane.ViewModels
private bool TryReportInvalidParameters()
{
var firstInvalidNode = PipelineNodes
var firstInvalidNode = GetNodesInExecutionScope()
.Where(n => n.IsEnabled)
.OrderBy(n => n.Order)
.FirstOrDefault(n => n.Parameters.Any(p => !p.IsValueValid));
@@ -552,6 +615,7 @@ namespace XplorePlane.ViewModels
{
PipelineNodes.Clear();
SelectedNode = null;
ExecutionEndNode = null;
PipelineName = "新建流水线";
PreviewImage = null;
_currentFilePath = null;
@@ -586,7 +650,7 @@ namespace XplorePlane.ViewModels
var dialog = new SaveFileDialog
{
Filter = "XP 模块流水线 (*.xpm)|*.xpm",
Filter = "XP 模块 (*.xpm)|*.xpm",
DefaultExt = ".xpm",
AddExtension = true,
FileName = PipelineName,
@@ -638,7 +702,7 @@ namespace XplorePlane.ViewModels
{
var dialog = new OpenFileDialog
{
Filter = "XP 模块流水线 (*.xpm)|*.xpm",
Filter = "XP 模块 (*.xpm)|*.xpm",
DefaultExt = ".xpm",
InitialDirectory = GetPipelineDirectory()
};
@@ -651,6 +715,7 @@ namespace XplorePlane.ViewModels
PipelineNodes.Clear();
SelectedNode = null;
ExecutionEndNode = null;
PipelineName = model.Name;
SelectedDevice = model.DeviceId;
@@ -679,6 +744,8 @@ namespace XplorePlane.ViewModels
PipelineNodes.Add(node);
}
UpdateExecutionRangeState();
_logger.Info("流水线已加载:{Name},节点数={Count}", model.Name, PipelineNodes.Count);
SetInfoStatus($"已加载流水线:{model.Name}{PipelineNodes.Count} 个节点)");
}
@@ -689,25 +756,6 @@ namespace XplorePlane.ViewModels
}
}
private void OpenToolbox() //打开图像工具箱
{
_logger.Info("OpenToolbox 被调用");
try
{
var window = new Views.OperatorToolboxWindow
{
Owner = System.Windows.Application.Current.MainWindow
};
_logger.Info("OperatorToolboxWindow 已创建,准备 Show()");
window.Show();
_logger.Info("OperatorToolboxWindow.Show() 完成");
}
catch (Exception ex)
{
_logger.Error(ex, "OpenToolbox 打开窗口失败");
}
}
private PipelineModel BuildPipelineModel()
{
return new PipelineModel
@@ -726,11 +774,47 @@ namespace XplorePlane.ViewModels
};
}
private static string GetPipelineDirectory()
private System.Collections.Generic.IEnumerable<PipelineNodeViewModel> GetNodesInExecutionScope()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XplorePlane", "Pipelines");
var orderedNodes = PipelineNodes.OrderBy(n => n.Order);
if (ExecutionEndNode == null)
return orderedNodes;
return orderedNodes.Where(n => n.Order <= ExecutionEndNode.Order);
}
private void UpdateExecutionRangeState()
{
if (_executionEndNode != null && !PipelineNodes.Contains(_executionEndNode))
_executionEndNode = null;
var endOrder = _executionEndNode?.Order;
foreach (var node in PipelineNodes)
{
node.IsExecutionEndNode = endOrder.HasValue && node.Order == endOrder.Value;
node.IsSkippedByExecutionRange = endOrder.HasValue && node.Order > endOrder.Value;
}
}
private string BuildExecutionStartMessage(int executionCount)
{
if (ExecutionEndNode == null)
return "正在执行流水线...";
return $"正在执行到“{ExecutionEndNode.DisplayName}” ({executionCount} 个有效节点)...";
}
private string BuildExecutionCompletedMessage(int executionCount)
{
if (ExecutionEndNode == null)
return "流水线执行完成";
return $"已执行到“{ExecutionEndNode.DisplayName}” ({executionCount} 个有效节点)";
}
private string GetPipelineDirectory()
{
var dir = _dataPathService.ToolsPath;
Directory.CreateDirectory(dir);
return dir;
}
@@ -11,6 +11,8 @@ namespace XplorePlane.ViewModels
private int _order;
private bool _isSelected;
private bool _isEnabled = true;
private bool _isExecutionEndNode;
private bool _isSkippedByExecutionRange;
public PipelineNodeViewModel(string operatorKey, string displayName, string iconPath = null)
{
@@ -51,9 +53,49 @@ namespace XplorePlane.ViewModels
public bool IsEnabled
{
get => _isEnabled;
set => SetProperty(ref _isEnabled, value);
set
{
if (SetProperty(ref _isEnabled, value))
RaisePropertyChanged(nameof(NodeStateText));
}
}
public ObservableCollection<ProcessorParameterVM> Parameters { get; }
public bool IsExecutionEndNode
{
get => _isExecutionEndNode;
set
{
if (SetProperty(ref _isExecutionEndNode, value))
RaisePropertyChanged(nameof(NodeStateText));
}
}
public bool IsSkippedByExecutionRange
{
get => _isSkippedByExecutionRange;
set
{
if (SetProperty(ref _isSkippedByExecutionRange, value))
RaisePropertyChanged(nameof(NodeStateText));
}
}
public string NodeStateText
{
get
{
if (IsExecutionEndNode && !IsEnabled)
return "执行到此(停用)";
if (IsExecutionEndNode)
return "执行到此";
if (!IsEnabled)
return "已停用";
if (IsSkippedByExecutionRange)
return "未参与本次执行";
return "已启用";
}
}
}
}
@@ -39,7 +39,15 @@ namespace XplorePlane.ViewModels
public string ParameterType { get; }
public bool HasOptions => Options is { Length: > 0 };
public bool IsBool => ParameterType == "bool";
public bool IsTextInput => !IsBool && !HasOptions;
public bool IsNumeric => ParameterType is "int" or "double";
public bool HasRange => IsNumeric && MinValue != null && MaxValue != null;
public bool IsSliderInput => HasRange;
public bool IsTextInput => !IsBool && !HasOptions && !IsSliderInput;
public double SliderMinimum => TryConvertToDouble(MinValue, out var minValue) ? minValue : 0d;
public double SliderMaximum => TryConvertToDouble(MaxValue, out var maxValue) ? maxValue : 100d;
public double SliderTickFrequency => ResolveTickFrequency();
public bool IsIntegerSlider => ParameterType == "int";
public bool IsValueValid
{
@@ -60,10 +68,46 @@ namespace XplorePlane.ViewModels
RaisePropertyChanged(nameof(Value));
RaisePropertyChanged(nameof(BoolValue));
RaisePropertyChanged(nameof(SelectedOption));
RaisePropertyChanged(nameof(SliderValue));
RaisePropertyChanged(nameof(DisplayValueText));
}
}
}
public double SliderValue
{
get => TryConvertToDouble(_value, out var sliderValue) ? sliderValue : SliderMinimum;
set
{
if (!IsSliderInput)
{
return;
}
Value = ParameterType == "int"
? (object)Convert.ToInt32(Math.Round(value, MidpointRounding.AwayFromZero), CultureInfo.InvariantCulture)
: Math.Round(value, ResolveDecimalPlaces(), MidpointRounding.AwayFromZero);
}
}
public string DisplayValueText
{
get
{
if (ParameterType == "int" && TryConvertToInt(_value, out var intValue))
{
return intValue.ToString(CultureInfo.InvariantCulture);
}
if (ParameterType == "double" && TryConvertToDouble(_value, out var doubleValue))
{
return doubleValue.ToString($"F{ResolveDecimalPlaces()}", CultureInfo.InvariantCulture);
}
return Convert.ToString(_value, CultureInfo.InvariantCulture) ?? string.Empty;
}
}
public bool BoolValue
{
get => ParameterType == "bool" && TryConvertToBool(_value, out var boolValue) && boolValue;
@@ -154,6 +198,58 @@ namespace XplorePlane.ViewModels
return true;
}
private double ResolveTickFrequency()
{
if (!HasRange)
{
return 1d;
}
if (ParameterType == "int")
{
return 1d;
}
double range = SliderMaximum - SliderMinimum;
if (range <= 1d)
{
return 0.01d;
}
if (range <= 10d)
{
return 0.1d;
}
return 1d;
}
private int ResolveDecimalPlaces()
{
if (ParameterType == "int")
{
return 0;
}
double tick = SliderTickFrequency;
if (tick >= 1d)
{
return 0;
}
if (tick >= 0.1d)
{
return 1;
}
if (tick >= 0.01d)
{
return 2;
}
return 3;
}
private static string NormalizeNumericText(string value)
{
return value.Trim().TrimEnd('、', '', ',', '。', '.', ';', '', ':', '');
+292 -62
View File
@@ -7,10 +7,13 @@ using System;
using System.Collections.ObjectModel;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using XplorePlane.Events;
using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.Views;
using XplorePlane.Views.Cnc;
@@ -18,17 +21,19 @@ using XP.Common.Logging.Interfaces;
using XP.Common.GeneralForm.Views;
using XP.Common.PdfViewer.Interfaces;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Services;
namespace XplorePlane.ViewModels
{
public class MainViewModel : BindableBase
{
private const double CncEditorHostWidth = 502d;
private const double CncEditorHostWidth = 452d;
private readonly ILoggerService _logger;
private readonly IContainerProvider _containerProvider;
private readonly IEventAggregator _eventAggregator;
private readonly IMainViewportService _mainViewportService;
private readonly IXpDataPathService _xpDataPathService;
private readonly CncEditorViewModel _cncEditorViewModel;
private readonly CncPageView _cncPageView;
@@ -65,6 +70,10 @@ namespace XplorePlane.ViewModels
public DelegateCommand OpenLibraryVersionsCommand { get; }
public DelegateCommand OpenUserManualCommand { get; }
public DelegateCommand OpenCameraSettingsCommand { get; }
public DelegateCommand OpenSettingsCommand { get; }
public DelegateCommand BrowseDataRootPathCommand { get; }
public DelegateCommand ResetDataRootPathCommand { get; }
public DelegateCommand SaveDataRootPathCommand { get; }
public DelegateCommand NewCncProgramCommand { get; }
public DelegateCommand SaveCncProgramCommand { get; }
public DelegateCommand LoadCncProgramCommand { get; }
@@ -73,6 +82,7 @@ namespace XplorePlane.ViewModels
public DelegateCommand InsertCompleteProgramCommand { get; }
public DelegateCommand InsertInspectionMarkerCommand { get; }
public DelegateCommand InsertInspectionModuleCommand { get; }
public DelegateCommand InsertBuiltInInspectionModuleCommand { get; }
public DelegateCommand InsertSaveNodeCommand { get; }
public DelegateCommand InsertPauseDialogCommand { get; }
public DelegateCommand InsertWaitDelayCommand { get; }
@@ -81,6 +91,8 @@ namespace XplorePlane.ViewModels
// 硬件命令
public DelegateCommand AxisResetCommand { get; }
public DelegateCommand OpenDoorCommand { get; }
public DelegateCommand CloseDoorCommand { get; }
public DelegateCommand OpenDetectorConfigCommand { get; }
public DelegateCommand OpenMotionDebugCommand { get; }
public DelegateCommand OpenPlcAddrConfigCommand { get; }
@@ -120,6 +132,34 @@ namespace XplorePlane.ViewModels
public bool IsUsingLiveDetectorSource => _mainViewportService.CurrentSourceMode == MainViewportSourceMode.LiveDetector;
public string DataRootPath
{
get => _dataRootPath;
set => SetProperty(ref _dataRootPath, value);
}
public string PlanRootPath => _xpDataPathService.PlanPath;
public string ToolsRootPath => _xpDataPathService.ToolsPath;
public string ResultsRootPath => _xpDataPathService.DataPath;
public string ReportRootPath => _xpDataPathService.ReportPath;
public ObservableCollection<BuiltInInspectionModuleItem> BuiltInInspectionModules { get; } = new();
public BuiltInInspectionModuleItem SelectedBuiltInInspectionModule
{
get => _selectedBuiltInInspectionModule;
set
{
if (SetProperty(ref _selectedBuiltInInspectionModule, value))
{
InsertBuiltInInspectionModuleCommand?.RaiseCanExecuteChanged();
}
}
}
/// <summary>右侧图像区域内容 | Right-side image panel content</summary>
public object ImagePanelContent
{
@@ -142,11 +182,11 @@ namespace XplorePlane.ViewModels
}
// 窗口引用(单例窗口防止重复打开)
private Window _motionDebugWindow;
private Window _detectorConfigWindow;
private Window _plcAddrConfigWindow;
private Window _realTimeLogViewerWindow;
private Window _settingsWindow;
private Window _toolboxWindow;
private Window _raySourceConfigWindow;
private object _imagePanelContent;
@@ -155,16 +195,21 @@ namespace XplorePlane.ViewModels
private bool _isCncEditorMode;
private string _licenseInfo = "当前时间";
private string _dataRootPath = string.Empty;
private BuiltInInspectionModuleItem _selectedBuiltInInspectionModule;
public MainViewModel(
ILoggerService logger,
IContainerProvider containerProvider,
IEventAggregator eventAggregator,
IMainViewportService mainViewportService)
IMainViewportService mainViewportService,
IXpDataPathService xpDataPathService)
{
_logger = logger?.ForModule<MainViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
_xpDataPathService = xpDataPathService ?? throw new ArgumentNullException(nameof(xpDataPathService));
_cncEditorViewModel = _containerProvider.Resolve<CncEditorViewModel>();
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
@@ -185,8 +230,8 @@ namespace XplorePlane.ViewModels
var node = _cncEditorViewModel.SelectedNode;
if (node?.ResultImage != null)
{
_logger.Info("[图像链路] 切换到节点 [{Name}],显示缓存结果图像", node.Name);
_mainViewportService.SetManualImage(node.ResultImage, $"CNC节点:{node.Name}");
_logger.Info("[Image] Switched to node [{Name}], showing cached result image.", node.Name);
_mainViewportService.SetManualImage(node.ResultImage, $"CNC node: {node.Name}");
}
}
};
@@ -205,15 +250,19 @@ namespace XplorePlane.ViewModels
ClearCommand = new DelegateCommand(OnClear);
EditPropertiesCommand = new DelegateCommand(OnEditProperties);
OpenImageProcessingCommand = new DelegateCommand(() => ShowWindow(new Views.ImageProcessingWindow(), "图像处理"));
LoadImageCommand = new DelegateCommand(ExecuteLoadImage);
OpenPipelineEditorCommand = new DelegateCommand(() => ShowWindow(new Views.PipelineEditorWindow(), "流水线编辑器"));
OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor);
OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编"));
OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编"));
OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox);
OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于"));
OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual);
OpenCameraSettingsCommand = new DelegateCommand(ExecuteOpenCameraSettings);
OpenSettingsCommand = new DelegateCommand(ExecuteOpenSettings);
BrowseDataRootPathCommand = new DelegateCommand(ExecuteBrowseDataRootPath);
ResetDataRootPathCommand = new DelegateCommand(ExecuteResetDataRootPath);
SaveDataRootPathCommand = new DelegateCommand(ExecuteSaveDataRootPath);
NewCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.NewProgramCommand.Execute()));
SaveCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.SaveProgramCommand.Execute()));
LoadCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.LoadProgramCommand.Execute()));
@@ -222,6 +271,9 @@ namespace XplorePlane.ViewModels
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertCompleteProgramCommand.Execute()));
InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertInspectionMarkerCommand.Execute()));
InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertInspectionModuleCommand.Execute()));
InsertBuiltInInspectionModuleCommand = new DelegateCommand(
async () => await ExecuteInsertBuiltInInspectionModuleAsync(),
CanExecuteInsertBuiltInInspectionModule);
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSaveNodeCommand.Execute()));
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute()));
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute()));
@@ -242,11 +294,12 @@ namespace XplorePlane.ViewModels
BubbleMeasureCommand = new DelegateCommand(ExecuteBubbleMeasure);
// 辅助线命令
ToggleCrosshairCommand = new DelegateCommand(() =>
_eventAggregator.GetEvent<ToggleCrosshairEvent>().Publish());
AxisResetCommand = new DelegateCommand(ExecuteAxisReset);
OpenDoorCommand = new DelegateCommand(ExecuteOpenDoor);
CloseDoorCommand = new DelegateCommand(ExecuteCloseDoor);
OpenDetectorConfigCommand = new DelegateCommand(ExecuteOpenDetectorConfig);
OpenMotionDebugCommand = new DelegateCommand(ExecuteOpenMotionDebug);
OpenPlcAddrConfigCommand = new DelegateCommand(ExecuteOpenPlcAddrConfig);
@@ -260,6 +313,8 @@ namespace XplorePlane.ViewModels
ImagePanelContent = new PipelineEditorView();
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
ImagePanelWidth = new GridLength(320);
DataRootPath = _xpDataPathService.RootPath;
LoadBuiltInInspectionModules();
_logger.Info("MainViewModel 已初始化");
}
@@ -293,7 +348,7 @@ namespace XplorePlane.ViewModels
private void ExecuteOpenToolbox()
{
ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "算子工具箱");
ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "Operator Toolbox");
}
private void ExecuteOpenCncEditor()
@@ -334,17 +389,17 @@ namespace XplorePlane.ViewModels
var manualPath = ConfigurationManager.AppSettings["UserManual"];
if (string.IsNullOrEmpty(manualPath))
{
_logger.Warn("未配置用户手册路径");
MessageBox.Show("未配置用户手册路径,请检查 App.config 中的 UserManual 配置项。",
"提示", MessageBoxButton.OK, MessageBoxImage.Warning);
_logger.Warn("User manual path is not configured.");
MessageBox.Show("User manual path is not configured. Please check the UserManual setting in App.config.",
"Info", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (!File.Exists(manualPath))
{
_logger.Warn("用户手册文件不存在:{Path}", manualPath);
MessageBox.Show($"用户手册文件不存在:\n{manualPath}",
"提示", MessageBoxButton.OK, MessageBoxImage.Warning);
_logger.Warn("User manual file not found: {Path}", manualPath);
MessageBox.Show($"User manual file not found:\n{manualPath}",
"Info", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
@@ -355,9 +410,9 @@ namespace XplorePlane.ViewModels
}
catch (Exception ex)
{
_logger.Error(ex, "打开用户手册失败");
MessageBox.Show($"打开用户手册失败:{ex.Message}",
"错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "Failed to open user manual.");
MessageBox.Show($"Failed to open user manual: {ex.Message}",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -368,7 +423,7 @@ namespace XplorePlane.ViewModels
var vm = _containerProvider.Resolve<NavigationPropertyPanelViewModel>();
if (!vm.IsCameraConnected)
{
MessageBox.Show("请先连接相机", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("Please connect the camera first", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
@@ -377,13 +432,146 @@ namespace XplorePlane.ViewModels
}
catch (Exception ex)
{
_logger.Error(ex, "打开相机设置失败");
_logger.Error(ex, "Failed to open camera settings.");
}
}
private void ExecuteOpenSettings()
{
try
{
ShowOrActivate(_settingsWindow, w => _settingsWindow = w,
() => new Views.SettingsWindow(this), "Settings");
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to open settings window");
MessageBox.Show($"Failed to open settings window: {ex.Message}",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void ExecuteBrowseDataRootPath()
{
try
{
var dialog = new OpenFolderDialog
{
Title = "选择 XP 数据根目录",
InitialDirectory = Directory.Exists(DataRootPath) ? DataRootPath : _xpDataPathService.RootPath
};
if (dialog.ShowDialog() == true)
{
DataRootPath = dialog.FolderName;
}
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to browse XP data root.");
MessageBox.Show($"Failed to browse data root: {ex.Message}",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void ExecuteResetDataRootPath()
{
DataRootPath = _xpDataPathService.DefaultRootPath;
}
private void ExecuteSaveDataRootPath()
{
try
{
_xpDataPathService.SaveRootPath(DataRootPath);
DataRootPath = _xpDataPathService.RootPath;
RaisePropertyChanged(nameof(PlanRootPath));
RaisePropertyChanged(nameof(ToolsRootPath));
RaisePropertyChanged(nameof(ResultsRootPath));
RaisePropertyChanged(nameof(ReportRootPath));
LoadBuiltInInspectionModules();
MessageBox.Show("XP data root saved. New save/load dialogs will use the new path immediately.",
"Info", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to save XP data root.");
MessageBox.Show($"Failed to save data root: {ex.Message}",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private bool CanExecuteInsertBuiltInInspectionModule()
{
return SelectedBuiltInInspectionModule != null;
}
private async Task ExecuteInsertBuiltInInspectionModuleAsync()
{
var module = SelectedBuiltInInspectionModule;
if (module == null)
return;
try
{
ShowCncEditor();
await _cncEditorViewModel.InsertInspectionModuleFromPipelineFileAsync(module.FilePath);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to insert built-in inspection module: {FilePath}", module.FilePath);
MessageBox.Show($"Failed to insert built-in inspection module: {ex.Message}",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void LoadBuiltInInspectionModules()
{
BuiltInInspectionModules.Clear();
try
{
var toolsPath = _xpDataPathService.ToolsPath;
if (!Directory.Exists(toolsPath))
{
SelectedBuiltInInspectionModule = null;
return;
}
var files = Directory
.EnumerateFiles(toolsPath, "*.xpm", SearchOption.AllDirectories)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new BuiltInInspectionModuleItem(
GetBuiltInModuleDisplayName(toolsPath, path),
path))
.ToList();
foreach (var file in files)
{
BuiltInInspectionModules.Add(file);
}
SelectedBuiltInInspectionModule = BuiltInInspectionModules.FirstOrDefault();
_logger.Info("Loaded {Count} built-in inspection modules from {ToolsPath}", BuiltInInspectionModules.Count, toolsPath);
}
catch (Exception ex)
{
SelectedBuiltInInspectionModule = null;
_logger.Error(ex, "Failed to load built-in inspection modules.");
}
}
private static string GetBuiltInModuleDisplayName(string toolsPath, string filePath)
{
var relativePath = Path.GetRelativePath(toolsPath, filePath);
var withoutExtension = Path.ChangeExtension(relativePath, null) ?? relativePath;
return withoutExtension.Replace(Path.DirectorySeparatorChar, '/');
}
private void ExecuteAxisReset()
{
var result = MessageBox.Show("确认执行轴复位操作?", "轴复位",
var result = MessageBox.Show("Confirm axis reset?", "Axis Reset",
MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (result != MessageBoxResult.OK)
return;
@@ -394,14 +582,54 @@ namespace XplorePlane.ViewModels
var resetResult = motionSystem.AxisReset.Reset();
if (!resetResult.Success)
{
MessageBox.Show($"轴复位失败:{resetResult.ErrorMessage}", "错误",
MessageBox.Show($"Axis reset failed: {resetResult.ErrorMessage}", "Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
catch (Exception ex)
{
_logger.Error(ex, "轴复位异常");
MessageBox.Show($"轴复位异常:{ex.Message}", "错误",
_logger.Error(ex, "Axis reset failed.");
MessageBox.Show($"Axis reset error: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void ExecuteOpenDoor()
{
try
{
var motionService = _containerProvider.Resolve<IMotionControlService>();
var result = motionService.OpenDoor();
if (!result.Success)
{
MessageBox.Show($"Open door failed: {result.ErrorMessage}", "Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Open door failed.");
MessageBox.Show($"Open door error: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void ExecuteCloseDoor()
{
try
{
var motionService = _containerProvider.Resolve<IMotionControlService>();
var result = motionService.CloseDoor();
if (!result.Success)
{
MessageBox.Show($"Close door failed: {result.ErrorMessage}", "Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Close door failed.");
MessageBox.Show($"Close door error: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -411,13 +639,13 @@ namespace XplorePlane.ViewModels
try
{
ShowOrActivate(_detectorConfigWindow, w => _detectorConfigWindow = w,
() => new XP.Hardware.Detector.Views.DetectorConfigWindow(), "探测器配置");
() => new XP.Hardware.Detector.Views.DetectorConfigWindow(), "Detector Config");
}
catch (Exception ex)
{
_logger.Error(ex, "打开探测器配置窗口失败");
MessageBox.Show($"打开探测器配置窗口失败:\n{ex.InnerException?.Message ?? ex.Message}",
"错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "Failed to open detector config window.");
MessageBox.Show($"Failed to open detector config window:\n{ex.InnerException?.Message ?? ex.Message}",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -436,7 +664,7 @@ namespace XplorePlane.ViewModels
private void ExecuteOpenRaySourceConfig()
{
ShowOrActivate(_raySourceConfigWindow, w => _raySourceConfigWindow = w,
() => new XP.Hardware.RaySource.Views.RaySourceConfigWindow(), "射线源配置");
() => new XP.Hardware.RaySource.Views.RaySourceConfigWindow(), "Ray Source Config");
}
private void ExecuteLoadImage()
@@ -459,20 +687,20 @@ namespace XplorePlane.ViewModels
bitmap.EndInit();
bitmap.Freeze();
_logger.Info("[图像链路] ExecuteLoadImage:加载图像 {Path},准备推送到 MainViewportService ManualImageLoadedEvent", dialog.FileName);
_logger.Info("[Image] ExecuteLoadImage loaded image {Path} and will push it to MainViewportService and ManualImageLoadedEvent.", dialog.FileName);
_mainViewportService.SetManualImage(bitmap, dialog.FileName);
// 同时发布事件,让 PipelineEditorViewModel 收到图像并触发流水线执行
// Publish the image to the pipeline editor at the same time.
_eventAggregator.GetEvent<ManualImageLoadedEvent>()
.Publish(new ManualImageLoadedPayload(bitmap, dialog.FileName));
_logger.Info("[图像链路] ManualImageLoadedEvent 已发布");
_logger.Info("[Image] ManualImageLoadedEvent published.");
RaisePropertyChanged(nameof(IsUsingLiveDetectorSource));
}
catch (Exception ex)
{
_logger.Error(ex, "加载图像失败:{Path}", dialog.FileName);
MessageBox.Show($"加载图像失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "Failed to load image: {Path}", dialog.FileName);
MessageBox.Show($"Failed to load image: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -484,7 +712,7 @@ namespace XplorePlane.ViewModels
private void ExecuteWarmUp()
{
var messageBoxResult = MessageBox.Show("确认执行射线源暖机操作?", "暖机",
var messageBoxResult = MessageBox.Show("Confirm X-ray source warm-up?", "Warm-up",
MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (messageBoxResult != MessageBoxResult.OK)
return;
@@ -495,18 +723,18 @@ namespace XplorePlane.ViewModels
var result = raySourceService.WarmUp();
if (!result.Success)
{
MessageBox.Show($"暖机失败:{result.ErrorMessage}", "错误",
MessageBox.Show($"Warm-up failed: {result.ErrorMessage}", "Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
else
{
_logger.Info("暖机命令已发送");
_logger.Info("Warm-up command sent.");
}
}
catch (Exception ex)
{
_logger.Error(ex, "暖机异常");
MessageBox.Show($"暖机异常:{ex.Message}", "错误",
_logger.Error(ex, "Warm-up failed.");
MessageBox.Show($"Warm-up error: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -521,7 +749,7 @@ namespace XplorePlane.ViewModels
if (viewportVm?.ImageSource != null) return true;
}
catch { }
HexMessageBox.Show("请先加载图像", MessageBoxButton.OK, MessageBoxImage.Information);
HexMessageBox.Show("Please load an image first", MessageBoxButton.OK, MessageBoxImage.Information);
return false;
}
@@ -529,28 +757,28 @@ namespace XplorePlane.ViewModels
private void ExecutePointDistanceMeasure()
{
if (!CheckImageLoaded()) return;
_logger.Info("点点距测量功能已触发");
_logger.Info("Point distance measurement triggered.");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.PointDistance);
}
private void ExecutePointLineDistanceMeasure()
{
if (!CheckImageLoaded()) return;
_logger.Info("点线距测量功能已触发");
_logger.Info("Point-line distance measurement triggered.");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.PointLineDistance);
}
private void ExecuteAngleMeasure()
{
if (!CheckImageLoaded()) return;
_logger.Info("角度测量功能已触发");
_logger.Info("Angle measurement triggered.");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.Angle);
}
private void ExecuteThroughHoleFillRateMeasure()
{
if (!CheckImageLoaded()) return;
_logger.Info("通孔填锡率测量功能已触发");
_logger.Info("Through-hole fill-rate measurement triggered.");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.ThroughHoleFillRate);
}
@@ -559,7 +787,7 @@ namespace XplorePlane.ViewModels
private void ExecuteBgaVoidMeasure()
{
if (!CheckImageLoaded()) return;
_logger.Info("BGA空隙测量功能已触发");
_logger.Info("BGA void measurement triggered.");
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.BgaVoid);
if (_bgaMeasurePanel != null && _bgaMeasurePanel.IsVisible)
@@ -624,12 +852,12 @@ namespace XplorePlane.ViewModels
private void ExecuteBubbleMeasure()
{
if (!CheckImageLoaded()) return;
_logger.Info("气泡测量功能已触发");
_logger.Info("Bubble measurement triggered.");
// 进入气泡测量模式
// Enter bubble measurement mode.
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.BubbleMeasure);
// 弹出工具面板
// Open the tool panel.
if (_bubbleMeasurePanel != null && _bubbleMeasurePanel.IsVisible)
{
_bubbleMeasurePanel.Activate();
@@ -642,7 +870,7 @@ namespace XplorePlane.ViewModels
};
_bubbleMeasurePanel.Closed += (s, e) =>
{
// 关闭面板时退出气泡测量模式
// Exit bubble measurement mode when the panel closes.
_eventAggregator.GetEvent<MeasurementToolEvent>().Publish(MeasurementToolMode.None);
};
_bubbleMeasurePanel.Show();
@@ -663,7 +891,7 @@ namespace XplorePlane.ViewModels
}
catch (Exception ex)
{
_logger.Error(ex, "打开语言设置失败");
_logger.Error(ex, "Failed to open language settings.");
}
}
@@ -675,38 +903,38 @@ namespace XplorePlane.ViewModels
private void OnNavigateHome()
{
_logger.Info("导航到主页");
LicenseInfo = "页";
_logger.Info("Navigated to home.");
LicenseInfo = "页";
}
private void OnNavigateInspect()
{
_logger.Info("导航到检测页面");
LicenseInfo = "检测页面";
_logger.Info("Navigated to inspection page.");
LicenseInfo = "Inspection";
}
private void OnOpenFile()
{
_logger.Info("打开文件");
_logger.Info("Open file.");
LicenseInfo = "打开文件";
}
private void OnExport()
{
_logger.Info("导出数据");
_logger.Info("Export data.");
LicenseInfo = "导出数据";
}
private void OnClear()
{
_logger.Info("清除数据");
_logger.Info("Clear data.");
LicenseInfo = "清除数据";
}
private void OnEditProperties()
{
_logger.Info("编辑属性");
LicenseInfo = "编辑属性";
_logger.Info("Edit properties.");
LicenseInfo = "Edit properties";
}
private void OnMainViewportStateChanged(object sender, EventArgs e)
@@ -722,12 +950,14 @@ namespace XplorePlane.ViewModels
{
if (payload?.Image == null)
{
_logger.Warn("[图像链路] OnPipelinePreviewUpdatedpayload 或 Image null,跳过");
_logger.Warn("[Image] OnPipelinePreviewUpdated skipped because payload or image is null.");
return;
}
_logger.Info("[图像链路] OnPipelinePreviewUpdated:收到流水线结果图像,推送到 MainViewportService");
_logger.Info("[Image] OnPipelinePreviewUpdated received a pipeline preview image and pushed it to MainViewportService.");
_mainViewportService.SetManualImage(payload.Image, string.Empty);
}
public sealed record BuiltInInspectionModuleItem(string DisplayName, string FilePath);
#endregion
}
}
@@ -6,6 +6,8 @@ using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Media.Imaging;
using XP.Camera;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
namespace XplorePlane.ViewModels
{
@@ -16,6 +18,7 @@ namespace XplorePlane.ViewModels
{
private static readonly ILogger _logger = Log.ForContext<NavigationPropertyPanelViewModel>();
private readonly ICameraController _camera;
private readonly IAppStateService _appStateService;
private volatile bool _liveViewRunning;
private bool _disposed;
@@ -161,9 +164,10 @@ namespace XplorePlane.ViewModels
#endregion Commands
public NavigationPropertyPanelViewModel(ICameraController camera)
public NavigationPropertyPanelViewModel(ICameraController camera, IAppStateService appStateService)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
ConnectCameraCommand = new DelegateCommand(ConnectCamera, () => !IsCameraConnected);
DisconnectCameraCommand = new DelegateCommand(DisconnectCamera, () => IsCameraConnected);
@@ -198,6 +202,7 @@ namespace XplorePlane.ViewModels
IsCameraConnected = true;
CameraStatusText = "已连接";
RefreshCameraParams();
SyncCameraStateToAppState();
StartGrab();
IsLiveViewEnabled = true;
}
@@ -217,12 +222,14 @@ namespace XplorePlane.ViewModels
CameraStatusText = $"已连接: {info.ModelName} (SN: {info.SerialNumber})";
_logger.Information("Camera connected: {ModelName}", info.ModelName);
RefreshCameraParams();
SyncCameraStateToAppState();
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to connect camera");
CameraStatusText = $"连接失败: {ex.Message}";
IsCameraConnected = false;
SyncCameraStateToAppState();
}
}
@@ -246,6 +253,7 @@ namespace XplorePlane.ViewModels
IsCameraGrabbing = false;
CameraStatusText = "未连接";
CameraImageSource = null;
SyncCameraStateToAppState();
_logger.Information("Camera disconnected");
}
}
@@ -257,6 +265,7 @@ namespace XplorePlane.ViewModels
_camera.StartGrabbing();
IsCameraGrabbing = true;
CameraStatusText = "采集中...";
SyncCameraStateToAppState();
// 如果已勾选实时,自动启动 Live View
if (IsLiveViewEnabled)
@@ -279,6 +288,7 @@ namespace XplorePlane.ViewModels
_camera.StopGrabbing();
IsCameraGrabbing = false;
CameraStatusText = "已停止采集";
SyncCameraStateToAppState();
}
catch (Exception ex)
{
@@ -402,11 +412,33 @@ namespace XplorePlane.ViewModels
IsCameraGrabbing = false;
CameraStatusText = "连接已断开";
CameraImageSource = null;
SyncCameraStateToAppState();
});
}
#endregion Camera Event Handlers
#region AppState Sync
/// <summary>
/// 将当前相机连接/采集状态同步到 AppStateService.CameraState。
/// 仅同步连接状态、采集状态和图像尺寸,不同步帧数据(避免高频触发 UI 刷新)。
/// </summary>
private void SyncCameraStateToAppState()
{
if (_appStateService == null) return;
_appStateService.UpdateCameraState(new CameraState(
IsConnected: IsCameraConnected,
IsStreaming: IsCameraGrabbing,
CurrentFrame: null,
Width: IsCameraConnected ? ImageWidth : 0,
Height: IsCameraConnected ? ImageHeight : 0,
FrameRate: 0));
}
#endregion AppState Sync
#region IDisposable
public void Dispose()
@@ -6,6 +6,8 @@ using System.Windows;
using System.Windows.Media;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.MainViewport;
namespace XplorePlane.ViewModels
@@ -18,6 +20,7 @@ namespace XplorePlane.ViewModels
private readonly ILoggerService _logger;
private readonly IMainViewportService _mainViewportService;
private readonly IEventAggregator _eventAggregator;
private readonly IAppStateService _appStateService;
private ImageSource _imageSource;
private string _imageInfo = "等待探测器图像...";
@@ -26,21 +29,42 @@ namespace XplorePlane.ViewModels
private Point? _measurePoint2;
private string _measurementResult;
// Task 5.2: IsRealtimeEnabled backing field
private bool _isRealtimeEnabled;
// Task 5.3: IsDetectorConnected backing field
private bool _isDetectorConnected = true;
// Task 5.4: IsCncRunning backing field
private bool _isCncRunning;
public ViewportPanelViewModel(
IMainViewportService mainViewportService,
IEventAggregator eventAggregator,
IAppStateService appStateService,
ILoggerService logger)
{
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
_logger = logger?.ForModule<ViewportPanelViewModel>() ?? throw new ArgumentNullException(nameof(logger));
CancelMeasurementCommand = new DelegateCommand(ExecuteCancelMeasurement);
// Task 5.5: ToggleRealtimeCommand
ToggleRealtimeCommand = new DelegateCommand(() => IsRealtimeEnabled = !IsRealtimeEnabled);
_mainViewportService.StateChanged += OnMainViewportStateChanged;
_eventAggregator.GetEvent<MeasurementToolEvent>()
.Subscribe(OnMeasurementToolActivated, ThreadOption.UIThread);
// Task 5.6: Subscribe to DetectorStateChanged
_appStateService.DetectorStateChanged += OnDetectorStateChanged;
// Task 5.7: Subscribe to DetectorDisconnectedEvent on UI thread
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
.Subscribe(OnDetectorDisconnectedForUI, ThreadOption.UIThread);
UpdateFromState(updateInfo: true);
}
@@ -56,6 +80,31 @@ namespace XplorePlane.ViewModels
set => SetProperty(ref _imageInfo, value);
}
// Task 5.2: IsRealtimeEnabled property (two-way binding)
public bool IsRealtimeEnabled
{
get => _isRealtimeEnabled;
set
{
if (SetProperty(ref _isRealtimeEnabled, value))
_mainViewportService.SetRealtimeDisplayEnabled(value);
}
}
// Task 5.3: IsDetectorConnected property (read-only, private setter)
public bool IsDetectorConnected
{
get => _isDetectorConnected;
private set
{
if (SetProperty(ref _isDetectorConnected, value))
RaisePropertyChanged(nameof(IsAnimatedSwitchEnabled));
}
}
// Task 5.4: IsAnimatedSwitchEnabled computed property
public bool IsAnimatedSwitchEnabled => _isDetectorConnected && !_isCncRunning;
public MeasurementToolMode CurrentMeasurementMode
{
get => _currentMeasurementMode;
@@ -100,6 +149,9 @@ namespace XplorePlane.ViewModels
public DelegateCommand CancelMeasurementCommand { get; }
// Task 5.5: ToggleRealtimeCommand
public DelegateCommand ToggleRealtimeCommand { get; }
public void ResetMeasurementState()
{
MeasurePoint1 = null;
@@ -151,10 +203,40 @@ namespace XplorePlane.ViewModels
{
ImageSource = _mainViewportService.CurrentDisplayImage;
// Task 5.8: Sync IsRealtimeEnabled from service
_isRealtimeEnabled = _mainViewportService.IsRealtimeDisplayEnabled;
RaisePropertyChanged(nameof(IsRealtimeEnabled));
// Task 5.8: Sync _isCncRunning from service and raise IsAnimatedSwitchEnabled
_isCncRunning = _mainViewportService.IsCncRunning;
RaisePropertyChanged(nameof(IsAnimatedSwitchEnabled));
if (updateInfo)
{
ImageInfo = _mainViewportService.CurrentDisplayInfo;
}
}
// Task 5.6: Handle DetectorStateChanged on background thread, dispatch to UI
private void OnDetectorStateChanged(object sender, StateChangedEventArgs<DetectorState> e)
{
Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
{
IsDetectorConnected = e.NewValue.IsConnected;
}));
}
// Task 5.7: Handle DetectorDisconnectedEvent on UI thread
private void OnDetectorDisconnectedForUI()
{
if (_isCncRunning)
{
MessageBox.Show(
"Detector disconnected, CNC has been automatically stopped. Please check the detector connection before continuing.",
"Detector Disconnected",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
<Window
<Window
x:Class="XplorePlane.Views.Cnc.CncEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+207 -35
View File
@@ -10,7 +10,7 @@
xmlns:views="clr-namespace:XplorePlane.Views"
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
d:DesignHeight="760"
d:DesignWidth="502"
d:DesignWidth="452"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d">
@@ -96,11 +96,26 @@
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="TreeToolbarButtonCompact" TargetType="Button" BasedOn="{StaticResource TreeToolbarButton}">
<Setter Property="Width" Value="28" />
<Setter Property="Height" Value="28" />
<Setter Property="MinWidth" Value="28" />
<Setter Property="Margin" Value="0,0,4,4" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="10.5" />
</Style>
<Style x:Key="TreeToolbarIcon" TargetType="Image">
<Setter Property="Width" Value="14" />
<Setter Property="Height" Value="14" />
<Setter Property="Margin" Value="0" />
<Setter Property="Stretch" Value="Uniform" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</UserControl.Resources>
<Border
Width="502"
MinWidth="502"
Width="452"
MinWidth="452"
HorizontalAlignment="Left"
Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
@@ -108,7 +123,7 @@
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="180" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
@@ -125,24 +140,109 @@
Background="{StaticResource HeaderBg}"
BorderBrush="{StaticResource SeparatorBrush}"
BorderThickness="0,0,0,1">
<WrapPanel>
<StackPanel>
<WrapPanel/>
<WrapPanel Margin="0,4,0,0" Visibility="Collapsed">
<Button
Command="{Binding NewProgramCommand}"
Content="新建"
Style="{StaticResource TreeToolbarButton}" />
Command="{Binding InsertReferencePointCommand}"
Content="参考点"
Style="{StaticResource TreeToolbarButtonCompact}" />
<Button
Command="{Binding SaveProgramCommand}"
Content="保存"
Style="{StaticResource TreeToolbarButton}" />
Command="{Binding InsertSavePositionCommand}"
Content="添加位置"
Style="{StaticResource TreeToolbarButtonCompact}" />
<Button
Command="{Binding LoadProgramCommand}"
Content="加载"
Style="{StaticResource TreeToolbarButton}" />
Command="{Binding InsertInspectionModuleCommand}"
Content="检测模块"
Style="{StaticResource TreeToolbarButtonCompact}" />
<Button
Command="{Binding ExportCsvCommand}"
Content="导出"
Style="{StaticResource TreeToolbarButton}" />
Command="{Binding InsertInspectionMarkerCommand}"
Content="检测标记"
Style="{StaticResource TreeToolbarButtonCompact}" />
<Button
Command="{Binding InsertPauseDialogCommand}"
Content="消息弹窗"
Style="{StaticResource TreeToolbarButtonCompact}" />
<Button
Command="{Binding InsertWaitDelayCommand}"
Content="插入等待"
Style="{StaticResource TreeToolbarButtonCompact}" />
<Button
Command="{Binding InsertCompleteProgramCommand}"
Content="完成"
Style="{StaticResource TreeToolbarButtonCompact}" />
</WrapPanel>
<WrapPanel Margin="0,4,0,0">
<WrapPanel.Resources>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
</Style>
</WrapPanel.Resources>
<Button
Command="{Binding InsertReferencePointCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="参考点">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/reference.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="参考点" />
</StackPanel>
</Button>
<Button
Command="{Binding InsertSavePositionCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="添加位置">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/add-pos.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="添加位置" />
</StackPanel>
</Button>
<Button
Command="{Binding InsertInspectionModuleCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="检测模块">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/Module.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="检测模块" />
</StackPanel>
</Button>
<Button
Command="{Binding InsertInspectionMarkerCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="检测标记">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/mark.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="检测标记" />
</StackPanel>
</Button>
<Button
Command="{Binding InsertPauseDialogCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="消息弹窗">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/message.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="消息弹窗" />
</StackPanel>
</Button>
<Button
Command="{Binding InsertWaitDelayCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="插入等待">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/wait.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="插入等待" />
</StackPanel>
</Button>
<Button
Command="{Binding InsertCompleteProgramCommand}"
Style="{StaticResource TreeToolbarButtonCompact}"
ToolTip="完成">
<StackPanel Orientation="Horizontal">
<Image Source="/Assets/Icons/finish.png" Style="{StaticResource TreeToolbarIcon}" />
<TextBlock Text="完成" />
</StackPanel>
</Button>
</WrapPanel>
</StackPanel>
</Border>
<TreeView
@@ -180,6 +280,7 @@
FontFamily="{StaticResource UiFont}"
FontSize="12"
FontWeight="SemiBold"
x:Name="ProgramRootNameText"
Text="{Binding DisplayName}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
@@ -188,6 +289,7 @@
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
<Setter TargetName="ProgramRootCard" Property="Background" Value="#E7F1FB" />
<Setter TargetName="ProgramRootCard" Property="BorderBrush" Value="#9FC6E8" />
<Setter TargetName="ProgramRootNameText" Property="Foreground" Value="#111111" />
</DataTrigger>
</DataTemplate.Triggers>
</HierarchicalDataTemplate>
@@ -205,6 +307,10 @@
BorderThickness="1"
CornerRadius="4">
<Grid x:Name="NodeRoot" MinHeight="23">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="15" />
<ColumnDefinition Width="20" />
@@ -212,7 +318,7 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<Grid Grid.RowSpan="2" Grid.Column="0">
<Border
x:Name="ChildStem"
Width="1"
@@ -230,6 +336,7 @@
</Grid>
<Border
Grid.RowSpan="2"
Grid.Column="1"
Width="16"
Height="16"
@@ -246,6 +353,7 @@
<TextBlock
x:Name="NodeNameText"
Grid.Row="0"
Grid.Column="2"
Margin="3,0,0,0"
VerticalAlignment="Center"
@@ -257,6 +365,7 @@
<StackPanel
x:Name="NodeActions"
Grid.Row="0"
Grid.Column="3"
Margin="0,0,2,0"
VerticalAlignment="Center"
@@ -275,6 +384,31 @@
FontSize="10"
ToolTip="删除" />
</StackPanel>
<Grid
x:Name="WaitDelayProgressHost"
Grid.Row="1"
Grid.Column="2"
Grid.ColumnSpan="2"
Margin="3,2,6,1"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ProgressBar
Height="6"
Minimum="0"
Maximum="100"
Value="{Binding ExecutionProgressPercent}" />
<TextBlock
Grid.Column="1"
Margin="6,-4,0,0"
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#444444"
Text="{Binding ExecutionProgressText}" />
</Grid>
</Grid>
</Border>
<DataTemplate.Triggers>
@@ -287,22 +421,30 @@
<Setter TargetName="NodeActions" Property="Visibility" Value="Visible" />
<Setter TargetName="NodeCard" Property="Background" Value="#DCEEFF" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#71A9DB" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="#1F2D3D" />
</DataTrigger>
<DataTrigger Binding="{Binding ExecutionState}" Value="Running">
<Setter TargetName="NodeCard" Property="Background" Value="#FF1E6FD9" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FF1E6FD9" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="White" />
<Setter TargetName="NodeCard" Property="Background" Value="#FFD54F" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#C89B00" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="#1F1F1F" />
</DataTrigger>
<DataTrigger Binding="{Binding ExecutionState}" Value="Succeeded">
<Setter TargetName="NodeCard" Property="Background" Value="#FF2E7D32" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FF2E7D32" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FF1B5E20" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="White" />
</DataTrigger>
<DataTrigger Binding="{Binding ExecutionState}" Value="Failed">
<Setter TargetName="NodeCard" Property="Background" Value="#FFC62828" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FFC62828" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#FF8E0000" />
<Setter TargetName="NodeNameText" Property="Foreground" Value="White" />
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWaitDelay}" Value="True" />
<Condition Binding="{Binding IsRunningNode}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter TargetName="WaitDelayProgressHost" Property="Visibility" Value="Visible" />
</MultiDataTrigger>
</DataTemplate.Triggers>
</HierarchicalDataTemplate>
</TreeView.Resources>
@@ -347,28 +489,44 @@
Visibility="{Binding SelectedNode.IsMotionSnapshotNode, Converter={StaticResource BoolToVisibilityConverter}}">
<UniformGrid Margin="8,8,8,6" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="XM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.XM, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="载物台 X (μm)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.StageX, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="YM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.YM, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="载物台 Y (μm)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.StageY, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="ZT" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZT, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="射线源 Z (μm)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.SourceZ, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="ZD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZD, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="探测器 Z (μm)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DetectorZ, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="TiltD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.TiltD, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="探测器摆动 (°)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DetectorSwing, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="Dist" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Dist, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="载物台旋转 (°)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.StageRotation, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="夹具旋转 (°)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FixtureRotation, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="FOD (μm)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FOD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="FDD (μm)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FDD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="放大倍率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Magnification, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</UniformGrid>
</GroupBox>
@@ -469,6 +627,20 @@
<StackPanel Margin="10,8,10,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="延时 (ms)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DelayMilliseconds, UpdateSourceTrigger=LostFocus}" />
<ProgressBar
Height="8"
Margin="0,2,0,0"
Minimum="0"
Maximum="100"
Value="{Binding SelectedNode.ExecutionProgressPercent}"
Visibility="{Binding SelectedNode.IsDelayProgressVisible, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Margin="0,4,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="{Binding SelectedNode.ExecutionProgressText}"
Visibility="{Binding SelectedNode.IsDelayProgressVisible, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
</GroupBox>
</StackPanel>
+5 -31
View File
@@ -19,14 +19,6 @@ namespace XplorePlane.Views.Cnc
/// </summary>
public partial class CncPageView : UserControl
{
private static readonly Brush SelectedNodeBackground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E7F0F7"));
private static readonly Brush SelectedNodeBorder = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CB9D1"));
private static readonly Brush HoverNodeBackground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F6FAFC"));
private static readonly Brush HoverNodeBorder = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#D7E4EE"));
private static readonly Brush SelectedNodeForeground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1F4E79"));
private static readonly Brush DefaultNodeForeground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#202020"));
private static readonly Brush TransparentBrush = Brushes.Transparent;
private CncInspectionModulePipelineViewModel _inspectionModulePipelineViewModel;
private readonly Dictionary<TextBox, Label> _textDisplayLabels = new();
private readonly Dictionary<CheckBox, Label> _checkDisplayLabels = new();
@@ -186,24 +178,9 @@ namespace XplorePlane.Views.Cnc
continue;
}
if (item.IsSelected)
{
card.Background = SelectedNodeBackground;
card.BorderBrush = SelectedNodeBorder;
ApplyNodeTextForeground(card, SelectedNodeForeground);
}
else if (card.IsMouseOver)
{
card.Background = HoverNodeBackground;
card.BorderBrush = HoverNodeBorder;
ApplyNodeTextForeground(card, DefaultNodeForeground);
}
else
{
card.Background = TransparentBrush;
card.BorderBrush = TransparentBrush;
ApplyNodeTextForeground(card, DefaultNodeForeground);
}
card.ClearValue(Border.BackgroundProperty);
card.ClearValue(Border.BorderBrushProperty);
ClearNodeTextForeground(card);
}
}
@@ -318,14 +295,11 @@ namespace XplorePlane.Views.Cnc
panel.Children.Insert(index + 1, companionControl);
}
private static void ApplyNodeTextForeground(Border card, Brush foreground)
private static void ClearNodeTextForeground(Border card)
{
foreach (var textBlock in FindVisualDescendants<TextBlock>(card))
{
if (textBlock.Visibility == Visibility.Visible)
{
textBlock.Foreground = foreground;
}
textBlock.ClearValue(TextBlock.ForegroundProperty);
}
}
@@ -53,7 +53,7 @@
Margin="0,0,4,3"
VerticalAlignment="Center"
Foreground="#333333"
Text="XM" />
Text="StageX" />
<TextBox
Grid.Row="0"
Grid.Column="1"
@@ -94,7 +94,7 @@
Margin="0,0,4,3"
VerticalAlignment="Center"
Foreground="#333333"
Text="YM" />
Text="StageY" />
<TextBox
Grid.Row="1"
Grid.Column="1"
@@ -127,7 +127,7 @@
Margin="0,0,4,3"
VerticalAlignment="Center"
Foreground="#333333"
Text="ZT" />
Text="SourceZ" />
<TextBox
Grid.Row="2"
Grid.Column="1"
@@ -151,7 +151,7 @@
Margin="0,0,4,3"
VerticalAlignment="Center"
Foreground="#333333"
Text="ZD" />
Text="DetectorZ" />
<TextBox
Grid.Row="3"
Grid.Column="1"
@@ -175,7 +175,7 @@
Margin="0,0,4,3"
VerticalAlignment="Center"
Foreground="#333333"
Text="TiltD" />
Text="DetectorSwing" />
<TextBox
Grid.Row="4"
Grid.Column="1"
@@ -199,7 +199,7 @@
Margin="0,0,4,0"
VerticalAlignment="Center"
Foreground="#333333"
Text="Dist" />
Text="FDD" />
<TextBox
Grid.Row="5"
Grid.Column="1"
@@ -63,7 +63,7 @@
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Foreground" Value="#1c1c1b" />
<Setter Property="BorderBrush" Value="#cdcbcb" />
<Setter Property="Padding" Value="4,6,4,4" />
<Setter Property="Padding" Value="3,4,3,3" />
</Style>
</UserControl.Resources>
@@ -145,7 +145,7 @@
<!-- 右侧:算子选择 + 参数配置 -->
<Border Grid.Column="3" Style="{StaticResource PanelBorderStyle}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="10,8,10,10">
<StackPanel Margin="8,6,8,8">
<GroupBox Margin="0,0,0,8" Header="选择算子">
<ComboBox
@@ -4,22 +4,30 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:prism="http://prismlibrary.com/"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="280">
d:DesignHeight="600"
d:DesignWidth="500">
<UserControl.Resources>
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#cdcbcb" />
<SolidColorBrush x:Key="CategoryBg" Color="#F5F7FA" />
<SolidColorBrush x:Key="HoverBg" Color="#E8F0FE" />
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<Style x:Key="OperatorToolboxTabItemStyle" TargetType="TabItem">
<Setter Property="Padding" Value="6,3" />
<Setter Property="Margin" Value="0,0,2,0" />
<Setter Property="FontFamily" Value="{StaticResource CsdFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="MinWidth" Value="0" />
</Style>
</UserControl.Resources>
<Border Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="1" CornerRadius="4">
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -27,14 +35,24 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- 标题(支持无边框窗口拖拽) -->
<Border x:Name="TitleBar" Grid.Row="0" Background="#0060A0" Padding="10,8">
<Border x:Name="TitleBar"
Grid.Row="0"
Background="#0060A0"
Padding="10,8">
<Grid>
<TextBlock Text="🧰 算子工具箱" FontFamily="{StaticResource CsdFont}"
FontWeight="Bold" FontSize="13" Foreground="White"
<TextBlock Text="算子工具箱"
FontFamily="{StaticResource CsdFont}"
FontWeight="Bold"
FontSize="13"
Foreground="White"
VerticalAlignment="Center" />
<Button x:Name="CloseBtn" HorizontalAlignment="Right" VerticalAlignment="Center"
Content="✕" FontSize="12" Foreground="White" Cursor="Hand"
<Button x:Name="CloseBtn"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Content="×"
FontSize="12"
Foreground="White"
Cursor="Hand"
Visibility="Collapsed"
ToolTip="关闭">
<Button.Style>
@@ -46,7 +64,8 @@
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="3" Padding="{TemplateBinding Padding}">
CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
@@ -64,76 +83,64 @@
</Grid>
</Border>
<!-- 搜索框 -->
<Border Grid.Row="1" Padding="8,6" BorderBrush="{StaticResource PanelBorder}"
<Border Grid.Row="1"
Padding="8,6"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1">
<TextBox Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
FontFamily="{StaticResource CsdFont}" FontSize="11"
Padding="6,4" BorderBrush="#cdcbcb" BorderThickness="1"
ToolTip="输入关键字搜索算子">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Text" Value="">
<Setter Property="Background">
<Setter.Value>
<VisualBrush AlignmentX="Left" AlignmentY="Center" Stretch="None">
<VisualBrush.Visual>
<TextBlock Text="🔍 搜索算子..." Foreground="#aaa"
FontFamily="Microsoft YaHei UI" FontSize="11"
Margin="4,0,0,0" />
</VisualBrush.Visual>
</VisualBrush>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
FontFamily="{StaticResource CsdFont}"
FontSize="11"
Padding="6,4"
BorderBrush="#cdcbcb"
BorderThickness="1"
ToolTip="输入关键字搜索算子" />
</Border>
<!-- 分组算子列表 -->
<ScrollViewer x:Name="ToolboxListBox" Grid.Row="2" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding FilteredGroups}">
<ItemsControl.ItemTemplate>
<Grid Grid.Row="2">
<TabControl x:Name="ToolboxListBox"
Margin="8"
ItemContainerStyle="{StaticResource OperatorToolboxTabItemStyle}"
SelectedItem="{Binding SelectedGroup, Mode=TwoWay}"
ItemsSource="{Binding FilteredGroups}">
<TabControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,0,0,2">
<!-- 分类标题 -->
<Border Background="{StaticResource CategoryBg}"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1"
Padding="10,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding CategoryIcon}" FontSize="13"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Text="{Binding CategoryName}"
FontFamily="Microsoft YaHei UI"
FontWeight="SemiBold" FontSize="12"
Foreground="#333" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- 分类下的算子 -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding CategoryIcon}"
FontSize="11"
VerticalAlignment="Center" />
<TextBlock Text="{Binding CategoryName}"
Margin="3,0,0,0"
FontSize="10.5"
VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Operators}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Padding="12,5,8,5" Cursor="Hand"
<Border Padding="12,6,8,6"
Cursor="Hand"
Background="Transparent"
BorderBrush="Transparent"
BorderBrush="#ECECEC"
BorderThickness="0,0,0,1">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#E8F0FE" />
<Setter Property="Background" Value="{StaticResource HoverBg}" />
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<Border Width="26" Height="26"
<Border Width="28"
Height="28"
Background="#EEF2FF"
CornerRadius="4" Margin="0,0,8,0">
CornerRadius="4"
Margin="0,0,8,0">
<TextBlock Text="{Binding IconPath}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
@@ -154,11 +161,35 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</TabControl.ContentTemplate>
</TabControl>
<Border Margin="8"
Padding="18"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding ElementName=ToolboxListBox, Path=HasItems}" Value="False">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="没有匹配的算子"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{StaticResource CsdFont}"
FontSize="12"
Foreground="#666666" />
</Border>
</Grid>
</Grid>
</Border>
</UserControl>
@@ -3,7 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:XplorePlane.Views"
Title="算子工具箱"
Width="260" Height="500"
Width="460" Height="540"
MinWidth="420" MinHeight="500"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
WindowStyle="None"
@@ -1,11 +1,12 @@
<UserControl
x:Class="XplorePlane.Views.PipelineEditorView"
x:Name="RootControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="700"
d:DesignWidth="350"
d:DesignWidth="300"
mc:Ignorable="d">
<UserControl.Resources>
@@ -16,7 +17,7 @@
<SolidColorBrush x:Key="DisabledNodeBg" Color="#F3F3F3" />
<SolidColorBrush x:Key="DisabledNodeLine" Color="#B9B9B9" />
<SolidColorBrush x:Key="DisabledNodeText" Color="#8A8A8A" />
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<FontFamily x:Key="UiFont">Microsoft YaHei UI</FontFamily>
<Style x:Key="PipelineNodeItemStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
@@ -36,15 +37,19 @@
</Style>
<Style x:Key="ToolbarBtn" TargetType="Button">
<Setter Property="Width" Value="52" />
<Setter Property="Height" Value="28" />
<Setter Property="Width" Value="32" />
<Setter Property="MinWidth" Value="32" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="#CDCBCB" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</UserControl.Resources>
@@ -59,7 +64,6 @@
<RowDefinition Height="4*" MinHeight="180" />
<RowDefinition Height="Auto" />
<RowDefinition Height="2*" MinHeight="80" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
@@ -73,37 +77,53 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
Orientation="Horizontal">
<Button
Command="{Binding NewPipelineCommand}"
Content="新建"
Style="{StaticResource ToolbarBtn}"
ToolTip="新建流水线" />
<Button
Command="{Binding SavePipelineCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存当前流水线" />
<Button
Width="60"
Command="{Binding SaveAsPipelineCommand}"
Content="另存为"
Style="{StaticResource ToolbarBtn}"
ToolTip="另存当前流水线" />
<Button
Width="52"
Command="{Binding LoadPipelineCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载流水线" />
<Button
Command="{Binding NewPipelineCommand}"
Style="{StaticResource ToolbarBtn}"
ToolTip="新建配方">
<TextBlock
FontFamily="Segoe MDL2 Assets"
FontSize="14"
Text="&#xE710;" />
</Button>
<Button
Command="{Binding SavePipelineCommand}"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存当前配方">
<TextBlock
FontFamily="Segoe MDL2 Assets"
FontSize="14"
Text="&#xE74E;" />
</Button>
<Button
Command="{Binding SaveAsPipelineCommand}"
Style="{StaticResource ToolbarBtn}"
ToolTip="另存当前配方">
<TextBlock
FontFamily="Segoe MDL2 Assets"
FontSize="14"
Text="&#xE792;" />
</Button>
<Button
Command="{Binding LoadPipelineCommand}"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载配方">
<TextBlock
FontFamily="Segoe MDL2 Assets"
FontSize="14"
Text="&#xE8E5;" />
</Button>
</StackPanel>
<TextBlock
Grid.Row="1"
Margin="2,4,2,0"
VerticalAlignment="Center"
FontFamily="{StaticResource CsdFont}"
FontFamily="{StaticResource UiFont}"
FontSize="11"
FontWeight="SemiBold"
Foreground="#333333"
@@ -130,10 +150,20 @@
x:Name="NodeContainer"
Margin="2"
Padding="2"
Tag="{Binding DataContext, ElementName=RootControl}"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="3">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="执行到此处"
Command="{Binding PlacementTarget.Tag.ExecuteToNodeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
<MenuItem Header="执行全部"
Command="{Binding PlacementTarget.Tag.ClearExecutionRangeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
</ContextMenu>
</Border.ContextMenu>
<Grid x:Name="NodeRoot" MinHeight="48">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="44" />
@@ -182,16 +212,16 @@
<StackPanel Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Center">
<TextBlock
x:Name="NodeTitle"
FontFamily="Microsoft YaHei UI"
FontFamily="{StaticResource UiFont}"
FontSize="12"
Text="{Binding DisplayName}" />
<TextBlock
x:Name="NodeState"
Margin="0,2,0,0"
FontFamily="Microsoft YaHei UI"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#6E6E6E"
Text="已启用" />
Text="{Binding NodeStateText}" />
</StackPanel>
</Grid>
</Border>
@@ -207,13 +237,37 @@
<Setter TargetName="IconBorder" Property="BorderBrush" Value="{StaticResource DisabledNodeLine}" />
<Setter TargetName="IconBorder" Property="Background" Value="#ECECEC" />
<Setter TargetName="NodeTitle" Property="Foreground" Value="{StaticResource DisabledNodeText}" />
<Setter TargetName="NodeState" Property="Text" Value="已停用" />
<Setter TargetName="NodeState" Property="Foreground" Value="#9A6767" />
</DataTrigger>
<DataTrigger Binding="{Binding IsExecutionEndNode}" Value="True">
<Setter TargetName="NodeContainer" Property="Background" Value="#E8F6EA" />
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#4F9D69" />
<Setter TargetName="IconBorder" Property="Background" Value="#E8F6EA" />
<Setter TargetName="IconBorder" Property="BorderBrush" Value="#4F9D69" />
<Setter TargetName="NodeState" Property="Foreground" Value="#2E7D32" />
</DataTrigger>
<DataTrigger Binding="{Binding IsSkippedByExecutionRange}" Value="True">
<Setter TargetName="NodeContainer" Property="Background" Value="#FAFAFA" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="0.72" />
<Setter TargetName="TopLine" Property="Stroke" Value="#D0D0D0" />
<Setter TargetName="BottomLine" Property="Stroke" Value="#D0D0D0" />
<Setter TargetName="IconBorder" Property="BorderBrush" Value="#C8C8C8" />
<Setter TargetName="IconBorder" Property="Background" Value="#F4F4F4" />
<Setter TargetName="NodeTitle" Property="Foreground" Value="#909090" />
<Setter TargetName="NodeState" Property="Foreground" Value="#A0A0A0" />
</DataTrigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ListBoxItem}, Path=IsSelected}" Value="True">
<Setter TargetName="NodeContainer" Property="Background" Value="#D9ECFF" />
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#5B9BD5" />
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsExecutionEndNode}" Value="True" />
<Condition Binding="{Binding RelativeSource={RelativeSource AncestorType=ListBoxItem}, Path=IsSelected}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter TargetName="NodeContainer" Property="Background" Value="#DFF0E3" />
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#3F8A58" />
</MultiDataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsEnabled}" Value="False" />
@@ -223,6 +277,15 @@
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#7E9AB6" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="1" />
</MultiDataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsSkippedByExecutionRange}" Value="True" />
<Condition Binding="{Binding RelativeSource={RelativeSource AncestorType=ListBoxItem}, Path=IsSelected}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter TargetName="NodeContainer" Property="Background" Value="#EEF1F4" />
<Setter TargetName="NodeContainer" Property="BorderBrush" Value="#AAB4BF" />
<Setter TargetName="NodeContainer" Property="Opacity" Value="0.9" />
</MultiDataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -240,7 +303,7 @@
<StackPanel Margin="8,6">
<TextBlock
Margin="0,0,0,4"
FontFamily="{StaticResource CsdFont}"
FontFamily="{StaticResource UiFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
@@ -263,25 +326,69 @@
<ColumnDefinition Width="100" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding DisplayName}"
TextTrimming="CharacterEllipsis" />
<Grid Grid.Column="1">
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSliderInput}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="58" />
</Grid.ColumnDefinitions>
<Slider
Grid.Column="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsSnapToTickEnabled="{Binding IsIntegerSlider}"
LargeChange="{Binding SliderTickFrequency}"
Maximum="{Binding SliderMaximum}"
Minimum="{Binding SliderMinimum}"
SmallChange="{Binding SliderTickFrequency}"
TickFrequency="{Binding SliderTickFrequency}"
Value="{Binding SliderValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Border
Grid.Column="1"
Padding="2,2"
Background="#F8F8F8"
BorderBrush="#CDCBCB"
BorderThickness="1"
CornerRadius="2">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding DisplayValueText}" />
</Border>
</Grid>
<TextBox
x:Name="TextValueEditor"
Grid.Column="1"
Padding="4,2"
Padding="2,2"
BorderBrush="#CDCBCB"
BorderThickness="1"
FontFamily="Microsoft YaHei UI"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="#CDCBCB" />
<Setter Property="Background" Value="White" />
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
@@ -295,13 +402,14 @@
</Style>
</TextBox.Style>
</TextBox>
<ComboBox
Grid.Column="1"
MinHeight="24"
Padding="4,1"
BorderBrush="#CDCBCB"
BorderThickness="1"
FontFamily="Microsoft YaHei UI"
FontFamily="{StaticResource UiFont}"
FontSize="11"
ItemsSource="{Binding Options}"
SelectedItem="{Binding SelectedOption, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
@@ -316,10 +424,11 @@
</Style>
</ComboBox.Style>
</ComboBox>
<CheckBox
Grid.Column="1"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontFamily="{StaticResource UiFont}"
FontSize="11"
IsChecked="{Binding BoolValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<CheckBox.Style>
@@ -339,42 +448,7 @@
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Border
Grid.Row="4"
Height="24"
Padding="6,0"
BorderThickness="0,1,0,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#F5F5F5" />
<Setter Property="BorderBrush" Value="{StaticResource PanelBorder}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Background" Value="#FFF1F1" />
<Setter Property="BorderBrush" Value="#D9534F" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
Text="{Binding StatusMessage, StringFormat='Status: {0}'}"
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#555" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Foreground" Value="#A12A2A" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</Grid>
</Border>
</UserControl>
@@ -59,6 +59,8 @@ namespace XplorePlane.Views
PipelineListBox.PreviewMouseMove += OnPreviewMouseMove;
PipelineListBox.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
PipelineListBox.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
PipelineListBox.PreviewMouseRightButtonDown -= OnPreviewMouseRightButtonDown;
PipelineListBox.PreviewMouseRightButtonDown += OnPreviewMouseRightButtonDown;
PipelineListBox.MouseDoubleClick -= OnMouseDoubleClick;
PipelineListBox.MouseDoubleClick += OnMouseDoubleClick;
PipelineListBox.PreviewKeyDown -= OnPreviewKeyDown;
@@ -133,6 +135,16 @@ namespace XplorePlane.Views
ResetDragState();
}
private void OnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
var clickedNode = FindNodeFromOriginalSource(e.OriginalSource);
if (clickedNode == null || IsInteractiveChild(e.OriginalSource))
return;
PipelineListBox.SelectedItem = clickedNode;
PipelineListBox.Focus();
}
private void OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as IPipelineEditorHostViewModel;
@@ -10,21 +10,11 @@
ShowInTaskbar="False">
<Grid Background="#F3F3F3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="240" MinWidth="200" />
<ColumnDefinition Width="*" MinWidth="400" />
<ColumnDefinition Width="250" MinWidth="250" />
<ColumnDefinition Width="320" MinWidth="300" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Margin="8"
Background="White"
BorderBrush="#D0D0D0"
BorderThickness="1"
CornerRadius="4">
<views:OperatorToolboxView />
</Border>
<Border Grid.Column="1"
Margin="8,8,4,8"
Background="White"
BorderBrush="#D0D0D0"
@@ -34,7 +24,7 @@
Background="White" />
</Border>
<Border Grid.Column="2"
<Border Grid.Column="1"
Margin="4,8,8,8"
Background="White"
BorderBrush="#D0D0D0"
+1 -1
View File
@@ -1,4 +1,4 @@
<UserControl
<UserControl
x:Class="XplorePlane.Views.ImagePanelView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+110 -83
View File
@@ -138,12 +138,14 @@
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="开门"
Command="{Binding OpenDoorCommand}"
telerik:ScreenTip.Title="Open Door"
Size="Medium"
SmallImage="/Assets/Icons/opendoor.png"
Text="开门" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="关门"
Command="{Binding CloseDoorCommand}"
telerik:ScreenTip.Title="Close Door"
Size="Medium"
SmallImage="/Assets/Icons/closedoor.png"
Text="关门" />
@@ -277,7 +279,34 @@
Size="Large"
SmallImage="/Assets/Icons/cnc.png"
Text="CNC 编辑" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开矩阵编排窗口,配置多工件阵列检测方案"
telerik:ScreenTip.Title="矩阵编排"
Command="{Binding OpenMatrixEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
<StackPanel Width="170">
<TextBlock
Margin="0,0,0,4"
HorizontalAlignment="Center"
Text="内置检测模块" />
<telerik:RadRibbonComboBox
Width="160"
ItemsSource="{Binding BuiltInInspectionModules}"
DisplayMemberPath="DisplayName"
SelectedItem="{Binding SelectedBuiltInInspectionModule}"
IsEditable="False" />
<telerik:RadRibbonButton
Margin="0,4,0,0"
telerik:ScreenTip.Description="从 Tools 目录扫描到的 .xpm 中选择一个配方,并插入到当前 CNC 程序中"
telerik:ScreenTip.Title="插入内置检测模块"
Command="{Binding InsertBuiltInInspectionModuleCommand}"
Size="Medium"
SmallImage="/Assets/Icons/Module.png"
Text="插入模块" />
</StackPanel>
<!--
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="参考点"
@@ -311,12 +340,7 @@
Command="{Binding InsertInspectionModuleCommand}"
SmallImage="/Assets/Icons/Module.png"
Text="检测模块" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="全部保存"
Size="Medium"
Command="{Binding SaveCncProgramCommand}"
SmallImage="/Assets/Icons/saveall.png"
Text="全部保存" />
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
@@ -332,14 +356,9 @@
SmallImage="/Assets/Icons/wait.png"
Text="插入等待" />
</StackPanel>
-->
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开矩阵编排窗口,配置多工件阵列检测方案"
telerik:ScreenTip.Title="矩阵编排"
Command="{Binding OpenMatrixEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="高级模块" IsEnabled="{Binding Path=CellsGroup.IsEnabled}">
@@ -385,7 +404,24 @@
SmallImage="/Assets/Icons/spiral.png" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="操作">
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="设置">
<telerik:RadRibbonGroup Header="全局设置">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
</telerik:RadRibbonGroup.Variants>
<telerik:RadRibbonButton
Size="Large"
SmallImage="/Assets/Icons/setting.png"
Command="{Binding OpenSettingsCommand}"
Text="全局设置" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup
telerik:ScreenTip.Description="Show the Alignment tab of the Format Cells dialog box."
telerik:ScreenTip.Title="Format Cells: Alignment"
@@ -402,22 +438,7 @@
<spreadsheetControls:RadVerticalAlignmentToBooleanConverter x:Key="verticalAlignmentToBooleanConverter" />
</telerik:RadRibbonGroup.Resources>
<StackPanel>
<telerik:RadRibbonToggleButton
telerik:ScreenTip.Description="暖机"
telerik:ScreenTip.Title="暖机"
Command="{Binding WarmUpCommand}"
Size="Medium"
SmallImage="/Assets/Icons/heat-engine.png"
Text="暖机" />
<telerik:RadRibbonToggleButton
telerik:ScreenTip.Description="轴复位"
telerik:ScreenTip.Title="轴复位"
Command="{Binding AxisResetCommand}"
Size="Medium"
SmallImage="/Assets/Icons/home.png"
Text="轴复位" />
</StackPanel>
<StackPanel/>
<StackPanel>
<telerik:RadRibbonToggleButton
@@ -428,56 +449,67 @@
SmallImage="/Assets/Icons/xray.png"
Text="射线源" />
<telerik:RadRibbonToggleButton
telerik:ScreenTip.Description="探测器控制"
telerik:ScreenTip.Title="探测器"
Command="{Binding OpenDetectorConfigCommand}"
Size="Medium"
SmallImage="/Assets/Icons/detector2.png"
Text="探测器" />
telerik:ScreenTip.Description="探测器控制"
telerik:ScreenTip.Title="探测器"
Command="{Binding OpenDetectorConfigCommand}"
Size="Medium"
SmallImage="/Assets/Icons/detector2.png"
Text="探测器" />
<telerik:RadRibbonToggleButton
telerik:ScreenTip.Description="运动控制"
telerik:ScreenTip.Title="运动控制"
Command="{Binding OpenMotionDebugCommand}"
Size="Medium"
SmallImage="/Assets/Icons/xyz.png"
Text="运动控制" />
telerik:ScreenTip.Description="运动控制"
telerik:ScreenTip.Title="运动控制"
Command="{Binding OpenMotionDebugCommand}"
Size="Medium"
SmallImage="/Assets/Icons/xyz.png"
Text="运动控制" />
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开相机参数设置窗口"
telerik:ScreenTip.Title="相机设置"
Command="{Binding OpenCameraSettingsCommand}"
Size="Medium"
SmallImage="/Assets/Icons/detector2.png"
Text="相机设置" />
telerik:ScreenTip.Description="打开相机参数设置窗口"
telerik:ScreenTip.Title="相机设置"
Command="{Binding OpenCameraSettingsCommand}"
Size="Medium"
SmallImage="/Assets/Icons/detector2.png"
Text="相机设置" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开 PLC 地址配置窗口"
telerik:ScreenTip.Title="PLC 地址配置"
Command="{Binding OpenPlcAddrConfigCommand}"
Size="Medium"
SmallImage="/Assets/Icons/tools.png"
Text="PLC 地址" />
telerik:ScreenTip.Description="打开 PLC 地址配置窗口"
telerik:ScreenTip.Title="PLC 地址配置"
Command="{Binding OpenPlcAddrConfigCommand}"
Size="Medium"
SmallImage="/Assets/Icons/tools.png"
Text="PLC 地址" />
</StackPanel>
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="多语言">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
</telerik:RadRibbonGroup.Variants>
<telerik:RadRibbonButton
telerik:ScreenTip.Description="切换应用程序显示语言"
telerik:ScreenTip.Title="多语言设置"
Size="Large"
SmallImage="/Assets/Icons/tools.png"
Command="{Binding OpenLanguageSwitcherCommand}"
Text="语言设置" />
telerik:ScreenTip.Description="切换应用程序显示语言"
telerik:ScreenTip.Title="多语言设置"
Size="Large"
SmallImage="/Assets/Icons/tools.png"
Command="{Binding OpenLanguageSwitcherCommand}"
Text="语言设置" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="日志">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
</telerik:RadRibbonGroup.Variants>
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开实时日志查看器"
telerik:ScreenTip.Title="查看日志"
Size="Large"
SmallImage="/Assets/Icons/message.png"
Command="{Binding OpenRealTimeLogViewerCommand}"
Text="查看日志" />
telerik:ScreenTip.Description="打开实时日志查看器"
telerik:ScreenTip.Title="查看日志"
Size="Large"
SmallImage="/Assets/Icons/message.png"
Command="{Binding OpenRealTimeLogViewerCommand}"
Text="查看日志" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="关于">
@@ -487,24 +519,19 @@
</telerik:RadRibbonGroup.Variants>
<telerik:RadRibbonButton
Size="Large"
SmallImage="/Assets/Icons/message.png"
Command="{Binding OpenUserManualCommand}"
Text="帮助文档" />
Size="Large"
SmallImage="/Assets/Icons/message.png"
Command="{Binding OpenUserManualCommand}"
Text="帮助文档" />
<telerik:RadRibbonButton
Size="Large"
SmallImage="/Assets/Icons/tools.png"
Command="{Binding OpenLibraryVersionsCommand}"
Text="关于" />
Size="Large"
SmallImage="/Assets/Icons/tools.png"
Command="{Binding OpenLibraryVersionsCommand}"
Text="关于" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
<telerik:RadRibbonView.ContextualGroups>
<telerik:RadRibbonContextualGroup
x:Name="PictureTools"
Header="Picture Tools"
IsActive="{Binding Path=PictureToolsTab.IsEnabled, Mode=OneWay}" />
</telerik:RadRibbonView.ContextualGroups>
</telerik:RadRibbonTab>
</telerik:RadRibbonView>
<Grid
@@ -1,4 +1,8 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Linq;
using Telerik.Windows.Controls;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
@@ -12,6 +16,8 @@ namespace XplorePlane.Views
{
InitializeComponent();
DataContext = viewModel;
}
}
}
@@ -26,7 +26,7 @@
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="#333333"
Text="相机预览" />
Text="导航" />
</Border>
<!-- 相机图像显示区域 -->
@@ -148,7 +148,7 @@ namespace XplorePlane.Views
{
if (DataContext is not ViewportPanelViewModel vm || vm.ImageSource is not BitmapSource bitmap)
{
MessageBox.Show("当前没有可保存的图像", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("No image available to save", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
SaveBitmapToFile(bitmap, "保存原始图像");
@@ -159,7 +159,7 @@ namespace XplorePlane.Views
var target = FindChildByName<Canvas>(RoiCanvas, "mainCanvas");
if (target == null)
{
MessageBox.Show("当前没有可保存的图像", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("No image available to save", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var width = (int)target.ActualWidth;
@@ -0,0 +1,169 @@
<Window x:Class="XplorePlane.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="系统设置"
Width="860"
Height="620"
MinWidth="760"
MinHeight="540"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="#F5F5F5">
<Window.Resources>
<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="#1F1F1F" />
<Setter Property="Margin" Value="0,0,0,12" />
</Style>
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="White" />
<Setter Property="BorderBrush" Value="#D9D9D9" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="16" />
<Setter Property="Margin" Value="0,0,0,12" />
</Style>
<Style x:Key="ActionButtonStyle" TargetType="Button">
<Setter Property="Height" Value="32" />
<Setter Property="MinWidth" Value="96" />
<Setter Property="Margin" Value="0,0,10,10" />
<Setter Property="Padding" Value="12,0" />
</Style>
</Window.Resources>
<DockPanel Margin="12">
<TabControl Background="White">
<TabItem Header="通用">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="通用设置" />
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock FontSize="13"
FontWeight="SemiBold"
Text="XP 数据目录" />
<TextBlock Margin="0,8,0,8"
Foreground="#666666"
Text="Plan 用于 CNC 默认保存和加载,Tools 用于流程图配方 xpm,Data 用于执行结果和中间图像,Report 为报告预留目录。" />
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="数据根目录" />
<TextBox Grid.Column="1"
Height="30"
Margin="0,0,10,0"
Padding="8,0"
Text="{Binding DataRootPath, UpdateSourceTrigger=PropertyChanged}" />
<Button Grid.Column="2"
Command="{Binding BrowseDataRootPathCommand}"
Content="浏览"
Style="{StaticResource ActionButtonStyle}" />
<Button Grid.Column="3"
Command="{Binding ResetDataRootPathCommand}"
Content="恢复默认"
Style="{StaticResource ActionButtonStyle}" />
<Button Grid.Column="4"
Command="{Binding SaveDataRootPathCommand}"
Content="保存"
Style="{StaticResource ActionButtonStyle}" />
</Grid>
</StackPanel>
</Border>
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock FontSize="13"
FontWeight="SemiBold"
Text="界面与使用习惯" />
<CheckBox Margin="0,12,0,0"
Content="启动时默认显示实时图像" />
<CheckBox Margin="0,8,0,0"
Content="允许自动恢复上次工作状态" />
<CheckBox Margin="0,8,0,0"
Content="启用状态栏详细提示" />
</StackPanel>
</Border>
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock FontSize="13"
FontWeight="SemiBold"
Text="调试" />
<WrapPanel>
<Button Command="{Binding OpenLibraryVersionsCommand}"
Content="版本信息"
Style="{StaticResource ActionButtonStyle}" />
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="报告">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="报告设置" />
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock FontSize="13"
FontWeight="SemiBold"
Text="报告模板" />
<TextBlock Margin="0,10,0,8"
Foreground="#666666"
Text="这里预留报告输出相关设置,可继续扩展公司信息、模板路径、签核信息和导出规则。" />
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="报告标题" />
<TextBox Grid.Column="1"
Height="30"
Padding="8,0"
Text="检测报告" />
</Grid>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="模板路径" />
<TextBox Grid.Column="1"
Height="30"
Padding="8,0"
Text="D:\XPData\Report\Templates\Default" />
</Grid>
<CheckBox Margin="0,12,0,0"
Content="导出时自动附带原始图像" />
<CheckBox Margin="0,8,0,0"
Content="导出时自动附带处理结果图像" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,13 @@
using System.Windows;
namespace XplorePlane.Views
{
public partial class SettingsWindow : Window
{
public SettingsWindow(object viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
}
+13 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
@@ -15,7 +15,11 @@
<ItemGroup>
<Page Remove="MainWindow.xaml" />
<Page Remove="Views\ImageProcessing\ImageProcessingPanelView.xaml" />
<Page Remove="Views\ImageProcessing\ImageProcessingWindow.xaml" />
<Page Remove="Views\ImageProcessing\PipelineEditorWindow.xaml" />
<Page Remove="Views\Main\MainWindowB.xaml" />
<Page Remove="Views\Main\NavigationPanelView.xaml" />
</ItemGroup>
<!-- NuGet 包引用 -->
@@ -146,7 +150,15 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Libs\Hardware\zh-TW\%(Filename)%(Extension)</Link>
</None>
<Compile Remove="Views\Hardware\**" />
<EmbeddedResource Remove="Views\Hardware\**" />
<None Remove="Views\Hardware\**" />
<Page Remove="Views\Hardware\**" />
<Compile Remove="Views\ImageProcessing\ImageProcessingPanelView.xaml.cs" />
<Compile Remove="Views\ImageProcessing\ImageProcessingWindow.xaml.cs" />
<Compile Remove="Views\ImageProcessing\PipelineEditorWindow.xaml.cs" />
<Compile Remove="Views\Main\MainWindowB.xaml.cs" />
<Compile Remove="Views\Main\NavigationPanelView.xaml.cs" />
<Content Include="XplorerPlane.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup />
</Project>