diff --git a/.gitignore b/.gitignore
index 5cf5a873..1331f632 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,3 +75,4 @@ Dump/
Report/
XplorePlane.Tests/TestResults/
ReleaseFiles/win-x64/
+XPData/
diff --git a/XP.Hardware.Detector/Abstractions/DetectorAcquisitionParameters.cs b/XP.Hardware.Detector/Abstractions/DetectorAcquisitionParameters.cs
new file mode 100644
index 00000000..3294c188
--- /dev/null
+++ b/XP.Hardware.Detector/Abstractions/DetectorAcquisitionParameters.cs
@@ -0,0 +1,14 @@
+namespace XP.Hardware.Detector.Abstractions
+{
+ ///
+ /// 探测器可下发采集参数 | Detector down-loadable acquisition parameters
+ /// 用于上层(如 CNC 位置节点)捕获并还原探测器采集参数。
+ ///
+ /// Binning 索引 | Binning index
+ /// PGA 灵敏度值 | PGA sensitivity value
+ /// 帧率 | Frame rate
+ public record DetectorAcquisitionParameters(
+ int BinningIndex,
+ int Pga,
+ decimal FrameRate);
+}
diff --git a/XP.Hardware.Detector/Services/DetectorService.cs b/XP.Hardware.Detector/Services/DetectorService.cs
index ccc4fc5d..542d09fa 100644
--- a/XP.Hardware.Detector/Services/DetectorService.cs
+++ b/XP.Hardware.Detector/Services/DetectorService.cs
@@ -647,6 +647,17 @@ namespace XP.Hardware.Detector.Services
};
}
+ ///
+ ///
+ /// TODO(待硬件层完善):当前硬件层未维护「运行时当前采集参数」的可读状态,暂返回 null。
+ /// 硬件层补齐 Binning/PGA/帧率的当前值读取后,应在此返回真实参数。
+ /// 返回 null 时,上层(CNC 位置节点)会跳过探测器参数快照捕获与还原。
+ ///
+ public DetectorAcquisitionParameters GetCurrentAcquisitionParameters()
+ {
+ return null;
+ }
+
///
/// 获取探测器实例或抛出异常 | Get detector instance or throw exception
///
diff --git a/XP.Hardware.Detector/Services/IDetectorService.cs b/XP.Hardware.Detector/Services/IDetectorService.cs
index d563d615..795e81dd 100644
--- a/XP.Hardware.Detector/Services/IDetectorService.cs
+++ b/XP.Hardware.Detector/Services/IDetectorService.cs
@@ -136,5 +136,17 @@ namespace XP.Hardware.Detector.Services
///
/// 校正能力描述,未初始化时返回默认值 | Correction capabilities, default if not initialized
CorrectionCapabilities GetCorrectionCapabilities();
+
+ ///
+ /// 读取当前探测器采集参数(Binning/PGA/帧率),供上层捕获并还原。
+ /// Get current detector acquisition parameters for upper-layer capture/restore.
+ ///
+ ///
+ /// TODO(待硬件层完善):当前硬件层尚未维护「运行时当前采集参数」的可读状态,
+ /// 默认实现返回 null。硬件层补齐后应返回真实的当前 Binning/PGA/帧率。
+ /// 上层(CNC 位置节点)在返回 null 时会跳过探测器参数快照捕获与还原。
+ ///
+ /// 当前采集参数;暂不可读时返回 null | Current acquisition parameters, or null if not available yet
+ DetectorAcquisitionParameters GetCurrentAcquisitionParameters();
}
}
diff --git a/XplorePlane.Tests/Services/CncHardwareNodeExecutionTests.cs b/XplorePlane.Tests/Services/CncHardwareNodeExecutionTests.cs
new file mode 100644
index 00000000..d4bde3e2
--- /dev/null
+++ b/XplorePlane.Tests/Services/CncHardwareNodeExecutionTests.cs
@@ -0,0 +1,427 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Moq;
+using Prism.Events;
+using XP.Common.Logging.Interfaces;
+using XP.Hardware.Detector.Abstractions;
+using XP.Hardware.Detector.Services;
+using XP.Hardware.MotionControl.Abstractions;
+using XP.Hardware.MotionControl.Services;
+using XP.Hardware.RaySource.Abstractions;
+using XP.Hardware.RaySource.Services;
+using XplorePlane.Events;
+using XplorePlane.Models;
+using XplorePlane.Services;
+using XplorePlane.Services.AppState;
+using XplorePlane.Services.Cnc;
+using XplorePlane.Services.InspectionArchive;
+using XplorePlane.Services.MainViewport;
+using Xunit;
+
+namespace XplorePlane.Tests.Services
+{
+ ///
+ /// CNC 硬件控制节点执行 + 位置节点硬件状态还原的单元测试。
+ /// 覆盖:几何/门/射线源/探测器校正动作节点,以及 SavePosition 的还原路径。
+ ///
+ public class CncHardwareNodeExecutionTests
+ {
+ private sealed class Harness
+ {
+ public CncExecutionService Service;
+ public Mock Archive;
+ public List Reports = new();
+ public IProgress Progress;
+
+ public NodeExecutionState FinalState(Guid id) =>
+ Reports.Where(r => r.NodeId == id).Select(r => r.State).LastOrDefault();
+ }
+
+ private static Harness CreateHarness(
+ IMotionControlService motion = null,
+ IRaySourceService ray = null,
+ IDetectorService detector = null,
+ ICncInteractionService interaction = null)
+ {
+ var archive = new Mock();
+ archive.Setup(a => a.BeginRunAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ archive.Setup(a => a.CompleteRunAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ archive.Setup(a => a.AppendCaptureNodeAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var logger = new Mock();
+ logger.Setup(l => l.ForModule()).Returns(logger.Object);
+
+ var appState = new Mock();
+ appState.SetupGet(s => s.MotionState).Returns(MotionState.Default);
+ appState.SetupGet(s => s.RaySourceState).Returns(RaySourceState.Default);
+ appState.SetupGet(s => s.DetectorState).Returns(DetectorState.Default);
+
+ var eventAgg = new Mock();
+ eventAgg.Setup(e => e.GetEvent()).Returns(new DetectorDisconnectedEvent());
+
+ var service = new CncExecutionService(
+ archive.Object, logger.Object, null, appState.Object,
+ null, null, eventAgg.Object, null,
+ motion, null, ray, null, detector, interaction);
+
+ var harness = new Harness { Service = service, Archive = archive };
+ harness.Progress = new SynchronousProgress(p => harness.Reports.Add(p));
+ return harness;
+ }
+
+ private static CncProgram Prog(params CncNode[] nodes) =>
+ new CncProgram(Guid.NewGuid(), "T", DateTime.UtcNow, DateTime.UtcNow, nodes.ToList().AsReadOnly());
+
+ private static XRayResult RayOk() => new XRayResult { Success = true };
+
+ // ── 几何节点 ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GeometryNode_ByFdd_CallsApplyGeometry()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.ApplyGeometry(It.IsAny(), It.IsAny())).Returns(MotionResult.Ok());
+ var h = CreateHarness(motion: motion.Object);
+ var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, Fod: 50, Fdd: 200, WaitForSettled: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ motion.Verify(m => m.ApplyGeometry(50, 200), Times.Once);
+ motion.Verify(m => m.ApplyGeometryByMagnification(It.IsAny(), It.IsAny()), Times.Never);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task GeometryNode_ByMagnification_CallsApplyGeometryByMagnification()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.ApplyGeometryByMagnification(It.IsAny(), It.IsAny())).Returns(MotionResult.Ok());
+ var h = CreateHarness(motion: motion.Object);
+ var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByMagnification, Fod: 50, Magnification: 4, WaitForSettled: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ motion.Verify(m => m.ApplyGeometryByMagnification(50, 4), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task GeometryNode_MotionServiceNull_NodeSucceedsDegraded()
+ {
+ var h = CreateHarness(motion: null);
+ var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, Fod: 50, Fdd: 200, WaitForSettled: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task GeometryNode_Failure_NodeFails()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.ApplyGeometry(It.IsAny(), It.IsAny())).Returns(MotionResult.Fail("几何越界"));
+ var h = CreateHarness(motion: motion.Object);
+ var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, Fod: 50, Fdd: 200, WaitForSettled: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
+ }
+
+ // ── 安全门节点 ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DoorControlNode_Open_CallsOpenDoor()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.OpenDoor()).Returns(MotionResult.Ok());
+ var h = CreateHarness(motion: motion.Object);
+ var node = new DoorControlNode(Guid.NewGuid(), 0, "门", DoorAction.Open, WaitForComplete: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ motion.Verify(m => m.OpenDoor(), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task DoorControlNode_Close_CallsCloseDoor()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.CloseDoor()).Returns(MotionResult.Ok());
+ var h = CreateHarness(motion: motion.Object);
+ var node = new DoorControlNode(Guid.NewGuid(), 0, "门", DoorAction.Close, WaitForComplete: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ motion.Verify(m => m.CloseDoor(), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task DoorControlNode_InterlockFailure_NodeFails()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.OpenDoor()).Returns(MotionResult.Fail("联锁拒绝"));
+ var h = CreateHarness(motion: motion.Object);
+ var node = new DoorControlNode(Guid.NewGuid(), 0, "门", DoorAction.Open, WaitForComplete: false);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
+ }
+
+ // ── 射线源节点 ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RaySourceControlNode_SetParameters_VoltageBeforeCurrent()
+ {
+ var calls = new List();
+ var ray = new Mock();
+ ray.Setup(r => r.SetVoltage(It.IsAny())).Returns(RayOk()).Callback(() => calls.Add("V"));
+ ray.Setup(r => r.SetCurrent(It.IsAny())).Returns(RayOk()).Callback(() => calls.Add("C"));
+ var h = CreateHarness(ray: ray.Object);
+ var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.SetParameters, Voltage: 120, Current: 200);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ ray.Verify(r => r.SetVoltage(120f), Times.Once);
+ ray.Verify(r => r.SetCurrent(200f), Times.Once);
+ Assert.Equal(new[] { "V", "C" }, calls);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task RaySourceControlNode_TurnOff_CallsTurnOff()
+ {
+ var ray = new Mock();
+ ray.Setup(r => r.TurnOff()).Returns(RayOk());
+ var h = CreateHarness(ray: ray.Object);
+ var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.TurnOff, Voltage: 0, Current: 0);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ ray.Verify(r => r.TurnOff(), Times.Once);
+ ray.Verify(r => r.SetVoltage(It.IsAny()), Times.Never);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task RaySourceControlNode_ServiceNull_NodeSucceedsDegraded()
+ {
+ var h = CreateHarness(ray: null);
+ var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.SetParameters, Voltage: 120, Current: 200);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task RaySourceControlNode_SetVoltageFailure_NodeFails()
+ {
+ var ray = new Mock();
+ ray.Setup(r => r.SetVoltage(It.IsAny())).Returns(XRayResult.Error("电压超限"));
+ var h = CreateHarness(ray: ray.Object);
+ var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.SetParameters, Voltage: 999, Current: 200);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ ray.Verify(r => r.SetCurrent(It.IsAny()), Times.Never);
+ Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
+ }
+
+ // ── 探测器校正节点 ────────────────────────────────────────────
+
+ [Fact]
+ public async Task DetectorCorrectionNode_Dark_CallsDarkCorrection()
+ {
+ var detector = new Mock();
+ detector.Setup(d => d.DarkCorrectionAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(DetectorResult.Success());
+ var h = CreateHarness(detector: detector.Object);
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Dark, FrameCount: 16);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ detector.Verify(d => d.DarkCorrectionAsync(16, It.IsAny()), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task DetectorCorrectionNode_Auto_CallsAutoCorrection()
+ {
+ var detector = new Mock();
+ detector.Setup(d => d.AutoCorrectionAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(DetectorResult.Success());
+ var h = CreateHarness(detector: detector.Object);
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Auto, FrameCount: 10);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ detector.Verify(d => d.AutoCorrectionAsync(10, It.IsAny()), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task DetectorCorrectionNode_BadPixel_IgnoresFrameCount()
+ {
+ var detector = new Mock();
+ detector.Setup(d => d.BadPixelCorrectionAsync(It.IsAny()))
+ .ReturnsAsync(DetectorResult.Success());
+ var h = CreateHarness(detector: detector.Object);
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.BadPixel, FrameCount: 99);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ detector.Verify(d => d.BadPixelCorrectionAsync(It.IsAny()), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task DetectorCorrectionNode_ServiceNull_NodeSucceedsDegraded()
+ {
+ var h = CreateHarness(detector: null);
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Auto, FrameCount: 10);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
+ }
+
+ [Fact]
+ public async Task DetectorCorrectionNode_Failure_NodeFails()
+ {
+ var detector = new Mock();
+ detector.Setup(d => d.AutoCorrectionAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(DetectorResult.Failure("校正失败"));
+ var h = CreateHarness(detector: detector.Object);
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Auto, FrameCount: 10);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
+ }
+
+ // ── 位置节点硬件状态还原 ──────────────────────────────────────
+
+ [Fact]
+ public async Task SavePosition_RestoresVoltageThenCurrent()
+ {
+ var calls = new List();
+ var ray = new Mock();
+ ray.SetupGet(r => r.IsXRayOn).Returns(true);
+ ray.Setup(r => r.SetVoltage(It.IsAny())).Returns(RayOk()).Callback(() => calls.Add("V"));
+ ray.Setup(r => r.SetCurrent(It.IsAny())).Returns(RayOk()).Callback(() => calls.Add("C"));
+ var h = CreateHarness(ray: ray.Object);
+ // RaySourceState.Power 存电流(μA):Voltage=100kV, Current=150μA
+ var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
+ RaySourceState: new RaySourceState(IsOn: true, Voltage: 100, Power: 150));
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ ray.Verify(r => r.SetVoltage(100f), Times.Once);
+ ray.Verify(r => r.SetCurrent(150f), Times.Once);
+ Assert.Equal(new[] { "V", "C" }, calls);
+ }
+
+ [Fact]
+ public async Task SavePosition_RayNotOn_PromptsConfirmAndAbortsWhenDeclined()
+ {
+ var ray = new Mock();
+ ray.SetupGet(r => r.IsXRayOn).Returns(false); // 当前未开
+ var interaction = new Mock();
+ interaction.Setup(i => i.ConfirmRayOnAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false); // 操作员放弃
+ var h = CreateHarness(ray: ray.Object, interaction: interaction.Object);
+ var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
+ RaySourceState: new RaySourceState(IsOn: true, Voltage: 100, Power: 150));
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ interaction.Verify(i => i.ConfirmRayOnAsync(It.IsAny(), It.IsAny()), Times.Once);
+ // 放弃后中止,不应再下发电压/电流
+ ray.Verify(r => r.SetVoltage(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task SavePosition_RestoreFailure_NotifiesAndSkipsCurrent()
+ {
+ var ray = new Mock();
+ ray.SetupGet(r => r.IsXRayOn).Returns(true);
+ ray.Setup(r => r.SetVoltage(It.IsAny())).Returns(XRayResult.Error("电压故障"));
+ var interaction = new Mock();
+ interaction.Setup(i => i.NotifyRestoreFailureAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ var h = CreateHarness(ray: ray.Object, interaction: interaction.Object);
+ var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
+ RaySourceState: new RaySourceState(IsOn: true, Voltage: 100, Power: 150));
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ interaction.Verify(i => i.NotifyRestoreFailureAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ ray.Verify(r => r.SetCurrent(It.IsAny()), Times.Never); // 电压失败后不再下发电流
+ }
+
+ [Fact]
+ public async Task SavePosition_WithDetectorAcq_AppliesParameters()
+ {
+ var detector = new Mock();
+ detector.Setup(d => d.ApplyParametersAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(DetectorResult.Success());
+ var h = CreateHarness(detector: detector.Object);
+ var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
+ RaySourceState: null,
+ DetectorAcq: new DetectorAcquisitionSnapshot(BinningIndex: 2, Pga: 3, FrameRate: 15m));
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ detector.Verify(d => d.ApplyParametersAsync(2, 3, 15m, It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task SavePosition_LegacyNoDetectorAcq_SkipsDetectorRestore()
+ {
+ var detector = new Mock();
+ var h = CreateHarness(detector: detector.Object);
+ var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
+ RaySourceState: null, DetectorAcq: null);
+
+ await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
+
+ detector.Verify(d => d.ApplyParametersAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ // ── 集成:混合硬件程序多节点执行 ──────────────────────────────
+
+ [Fact]
+ public async Task Integration_GeometryThenCorrection_BothExecutedInOrder()
+ {
+ var motion = new Mock();
+ motion.Setup(m => m.ApplyGeometry(It.IsAny(), It.IsAny())).Returns(MotionResult.Ok());
+ var detector = new Mock();
+ detector.Setup(d => d.AutoCorrectionAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(DetectorResult.Success());
+ var h = CreateHarness(motion: motion.Object, detector: detector.Object);
+
+ var geo = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, 50, 200, WaitForSettled: false);
+ var corr = new DetectorCorrectionNode(Guid.NewGuid(), 1, "校正", DetectorCorrectionType.Auto, 10);
+
+ await h.Service.ExecuteAsync(Prog(geo, corr), h.Progress, CancellationToken.None);
+
+ motion.Verify(m => m.ApplyGeometry(50, 200), Times.Once);
+ detector.Verify(d => d.AutoCorrectionAsync(10, It.IsAny()), Times.Once);
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(geo.Id));
+ Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(corr.Id));
+ }
+ }
+}
diff --git a/XplorePlane.Tests/Services/CncProgramServiceTests.cs b/XplorePlane.Tests/Services/CncProgramServiceTests.cs
index 05e4352d..a373cf58 100644
--- a/XplorePlane.Tests/Services/CncProgramServiceTests.cs
+++ b/XplorePlane.Tests/Services/CncProgramServiceTests.cs
@@ -45,5 +45,171 @@ namespace XplorePlane.Tests.Services
var savePosition = Assert.IsType(Assert.Single(deserialized.Nodes));
Assert.True(savePosition.SaveImage);
}
+
+ private static CncProgramService CreateService()
+ {
+ var appState = new Mock();
+ appState.SetupGet(s => s.MotionState).Returns(MotionState.Default);
+ appState.SetupGet(s => s.RaySourceState).Returns(RaySourceState.Default);
+ appState.SetupGet(s => s.DetectorState).Returns(DetectorState.Default);
+
+ var raySource = new Mock();
+ var logger = new Mock();
+ logger.Setup(l => l.ForModule()).Returns(logger.Object);
+
+ var permissionService = new Mock();
+ permissionService.Setup(p => p.HasPermission(It.IsAny())).Returns(true);
+
+ return new CncProgramService(appState.Object, raySource.Object, logger.Object, permissionService.Object);
+ }
+
+ private static CncProgram Roundtrip(CncProgramService service, params CncNode[] nodes)
+ {
+ var program = new CncProgram(
+ Guid.NewGuid(), "Program", DateTime.UtcNow, DateTime.UtcNow,
+ new List(nodes).AsReadOnly());
+ var json = service.Serialize(program);
+ return service.Deserialize(json);
+ }
+
+ [Fact]
+ public void SerializeDeserialize_RaySourceControlNode_PreservesAllFields()
+ {
+ var service = CreateService();
+ var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源控制_0",
+ RaySourceAction.TurnOn, Voltage: 120, Current: 200, WaitForStable: true, StableTimeoutMs: 8000);
+
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.Equal(RaySourceAction.TurnOn, result.Action);
+ Assert.Equal(120, result.Voltage);
+ Assert.Equal(200, result.Current);
+ Assert.True(result.WaitForStable);
+ Assert.Equal(8000, result.StableTimeoutMs);
+ }
+
+ [Fact]
+ public void SerializeDeserialize_DetectorCorrectionNode_PreservesAllFields()
+ {
+ var service = CreateService();
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "探测器校正_0",
+ DetectorCorrectionType.Dark, FrameCount: 20);
+
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.Equal(DetectorCorrectionType.Dark, result.CorrectionType);
+ Assert.Equal(20, result.FrameCount);
+ }
+
+ [Fact]
+ public void SerializeDeserialize_DoorControlNode_PreservesAllFields()
+ {
+ var service = CreateService();
+ var node = new DoorControlNode(Guid.NewGuid(), 0, "安全门_0",
+ DoorAction.Open, WaitForComplete: true, TimeoutMs: 12000);
+
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.Equal(DoorAction.Open, result.Action);
+ Assert.True(result.WaitForComplete);
+ Assert.Equal(12000, result.TimeoutMs);
+ }
+
+ [Fact]
+ public void SerializeDeserialize_GeometryNode_PreservesAllFields()
+ {
+ var service = CreateService();
+ var node = new GeometryNode(Guid.NewGuid(), 0, "几何_0",
+ GeometryMode.ByMagnification, Fod: 50.5, Fdd: 0, Magnification: 4.0, WaitForSettled: true);
+
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.Equal(GeometryMode.ByMagnification, result.Mode);
+ Assert.Equal(50.5, result.Fod);
+ Assert.Equal(4.0, result.Magnification);
+ Assert.True(result.WaitForSettled);
+ }
+
+ [Fact]
+ public void SerializeDeserialize_SavePositionWithDetectorAcq_PreservesSnapshot()
+ {
+ var service = CreateService();
+ var node = new SavePositionNode(Guid.NewGuid(), 0, "检测位置_0", MotionState.Default,
+ RaySourceState: new RaySourceState(true, 100, 150),
+ SaveImage: true,
+ DetectorAcq: new DetectorAcquisitionSnapshot(BinningIndex: 1, Pga: 3, FrameRate: 15m));
+
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.NotNull(result.DetectorAcq);
+ Assert.Equal(1, result.DetectorAcq.BinningIndex);
+ Assert.Equal(3, result.DetectorAcq.Pga);
+ Assert.Equal(15m, result.DetectorAcq.FrameRate);
+ Assert.Equal(100, result.RaySourceState.Voltage);
+ Assert.Equal(150, result.RaySourceState.Power);
+ }
+
+ [Fact]
+ public void Deserialize_LegacySavePositionWithoutDetectorAcq_LoadsWithNullSnapshot()
+ {
+ var service = CreateService();
+ // 模拟旧 .xp:SavePosition 节点不含 DetectorAcq 字段
+ var legacyJson = @"{
+ ""Id"": ""11111111-1111-1111-1111-111111111111"",
+ ""Name"": ""LegacyProgram"",
+ ""CreatedAt"": ""2024-01-01T00:00:00Z"",
+ ""UpdatedAt"": ""2024-01-01T00:00:00Z"",
+ ""Nodes"": [
+ {
+ ""$type"": ""SavePosition"",
+ ""Id"": ""22222222-2222-2222-2222-222222222222"",
+ ""Index"": 0,
+ ""Name"": ""检测位置_0"",
+ ""MotionState"": null,
+ ""SaveImage"": true
+ }
+ ]
+ }";
+
+ var deserialized = service.Deserialize(legacyJson);
+ var savePosition = Assert.IsType(Assert.Single(deserialized.Nodes));
+ Assert.Null(savePosition.DetectorAcq);
+ Assert.True(savePosition.SaveImage);
+ }
+
+ [Fact]
+ public void Deserialize_DetectorCorrectionFrameCountOutOfRange_IsClamped()
+ {
+ var service = CreateService();
+ // FrameCount 超上限(9999)应被截断到 1000
+ var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Dark, FrameCount: 9999);
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.Equal(1000, result.FrameCount);
+ }
+
+ [Fact]
+ public void Deserialize_RaySourceStableTimeoutOutOfRange_IsClamped()
+ {
+ var service = CreateService();
+ // StableTimeoutMs 超上限(999999)应被截断到 600000
+ var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.TurnOn, 120, 200, WaitForStable: true, StableTimeoutMs: 999999);
+ var result = Assert.IsType(Assert.Single(Roundtrip(service, node).Nodes));
+ Assert.Equal(600000, result.StableTimeoutMs);
+ }
+
+ [Fact]
+ public void SerializeDeserialize_MixedHardwareProgram_PreservesAllNodes()
+ {
+ var service = CreateService();
+ var geo = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, 50, 200);
+ var pos = new SavePositionNode(Guid.NewGuid(), 1, "位置", MotionState.Default,
+ RaySourceState: new RaySourceState(true, 100, 150),
+ DetectorAcq: new DetectorAcquisitionSnapshot(1, 2, 15m));
+ var corr = new DetectorCorrectionNode(Guid.NewGuid(), 2, "校正", DetectorCorrectionType.Auto, 10);
+
+ var rt = Roundtrip(service, geo, pos, corr);
+
+ Assert.Equal(3, rt.Nodes.Count);
+ Assert.IsType(rt.Nodes[0]);
+ var posRt = Assert.IsType(rt.Nodes[1]);
+ Assert.IsType(rt.Nodes[2]);
+ Assert.NotNull(posRt.DetectorAcq);
+ Assert.Equal(15m, posRt.DetectorAcq.FrameRate);
+ }
}
}
diff --git a/XplorePlane.Tests/Services/InspectionArchiveServiceTests.cs b/XplorePlane.Tests/Services/InspectionArchiveServiceTests.cs
index 0a7f3cbf..86d03663 100644
--- a/XplorePlane.Tests/Services/InspectionArchiveServiceTests.cs
+++ b/XplorePlane.Tests/Services/InspectionArchiveServiceTests.cs
@@ -225,6 +225,13 @@ namespace XplorePlane.Tests.Services
var mark = CreateMark(InspectionMarkType.Fail, InspectionMarkStatus.Fail, "缺陷-中文备注");
mark.SnapshotImageRelativePath = "documentation/marks/001_abcd1234/snapshot.bmp";
await archive.SaveMarkAsync(map, mark);
+ run.MapId = map.MapId;
+ run.MapSchemaVersion = map.SchemaVersion;
+ run.OverviewImageRelativePath = map.OverviewImageRelativePath;
+ run.ImageWidth = map.ImageWidth;
+ run.ImageHeight = map.ImageHeight;
+ run.MapUpdatedAt = map.UpdatedAtUtc;
+ await archive.UpdateRunMapAsync(run);
await archive.CompleteRunAsync(run.RunId, overallPass: true);
@@ -245,6 +252,12 @@ namespace XplorePlane.Tests.Services
var detail = JsonSerializer.Deserialize(json, opts);
Assert.NotNull(detail);
Assert.Equal(2, detail.SchemaVersion);
+ Assert.NotNull(detail.Map);
+ Assert.Equal(map.MapId, detail.Map.MapId);
+ Assert.Equal("documentation/inspection-map.xpmap", detail.Map.DocumentPath);
+ Assert.Equal("documentation/overview.bmp", detail.Map.OverviewImagePath);
+ Assert.Equal("NavigationCameraOverview", detail.Map.OverviewImageRole);
+ Assert.Equal("DetectorRegionImage", detail.Map.LinkedImageRole);
Assert.NotNull(detail.Batch);
Assert.Equal(2, detail.Batch.TotalPositions);
var roundTrippedMark = Assert.Single(detail.Marks);
diff --git a/XplorePlane.Tests/Services/InspectionResultStoreTests.cs b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs
index 6e19edd8..0009df81 100644
--- a/XplorePlane.Tests/Services/InspectionResultStoreTests.cs
+++ b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs
@@ -76,7 +76,15 @@ namespace XplorePlane.Tests.Services
PipelineId = pipelineA.Id,
PipelineName = pipelineA.Name,
NodePass = true,
- DurationMs = 135
+ DurationMs = 135,
+ PositionState = new InspectionPositionStateRecord
+ {
+ Motion = new MotionState(100, 200, 300, 400, 5, 600, 0, 0, 0, 0, 0, 0),
+ RaySource = new RaySourceReading { IsOn = true, VoltageKv = 90, CurrentUa = 120, PowerW = 10.8 },
+ Detector = new DetectorState(true, true, 30, "111x144"),
+ ImageInfo = "111x144",
+ CapturedAtUtc = startedAt.AddSeconds(1)
+ }
},
new[]
{
@@ -184,12 +192,24 @@ namespace XplorePlane.Tests.Services
Assert.Equal(3, detail.Metrics.Count);
Assert.Equal(4, detail.Assets.Count);
Assert.Equal(2, detail.PipelineSnapshots.Count);
+ var state = Assert.Single(detail.PositionStates);
+ Assert.Equal(node1Id, state.NodeId);
+ Assert.Equal(100, state.Motion.StageX);
+ Assert.Equal(90, state.RaySource.VoltageKv);
+ Assert.Equal(120, state.RaySource.CurrentUa);
+ Assert.Equal("111x144", state.Detector.Resolution);
Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass);
Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass);
Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash)));
var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json");
Assert.True(File.Exists(manifestPath));
+ var manifestJson = await File.ReadAllTextAsync(manifestPath);
+ using var manifest = JsonDocument.Parse(manifestJson);
+ var positionState = manifest.RootElement.GetProperty("PositionStates")[0];
+ Assert.Equal(90, positionState.GetProperty("RaySource").GetProperty("VoltageKv").GetDouble());
+ Assert.Equal(120, positionState.GetProperty("RaySource").GetProperty("CurrentUa").GetDouble());
+ Assert.Equal(100, positionState.GetProperty("Motion").GetProperty("StageX").GetDouble());
}
[Fact]
diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs
index a6a03f5b..944e01bb 100644
--- a/XplorePlane/App.xaml.cs
+++ b/XplorePlane/App.xaml.cs
@@ -406,6 +406,9 @@ namespace XplorePlane
// 若配置为模拟探测器,自动初始化并启动采集(无需用户手动操作)
await TryAutoStartSimulatedDetectorAsync();
+ // 若配置为虚拟相机,自动连接并启动实时预览(无需用户手动操作)
+ TryAutoStartSimulatedCamera();
+
// [DEV] 相机状态通知已屏蔽
// try
// {
@@ -475,6 +478,30 @@ namespace XplorePlane
}
}
+ ///
+ /// 若 config.json 中 CameraType = Simulated,自动连接虚拟相机并启动实时预览。
+ ///
+ private void TryAutoStartSimulatedCamera()
+ {
+ try
+ {
+ var camera = Container.Resolve();
+ if (camera is not XP.Camera.SimulatedCameraController)
+ return;
+
+ Log.Information("[SimulatedCamera] 检测到虚拟相机模式,自动连接...");
+
+ var navVm = Container.Resolve();
+ navVm.OnCameraReady();
+
+ Log.Information("[SimulatedCamera] 虚拟相机已自动连接并启动实时预览");
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "[SimulatedCamera] 虚拟相机自动启动异常");
+ }
+ }
+
///
/// 执行授权检查,授权失败时显示错误消息 | Perform license check, show error message on failure
///
@@ -661,6 +688,7 @@ namespace XplorePlane
// ── CNC / 矩阵编排 / 测量数据服务(单例)──
containerRegistry.RegisterSingleton();
+ containerRegistry.RegisterSingleton();
containerRegistry.RegisterSingleton();
containerRegistry.RegisterSingleton();
// 统一资产路径解析器(无状态单例,结果存储与标记图存储共用)
diff --git a/XplorePlane/Models/CncModels.cs b/XplorePlane/Models/CncModels.cs
index c6429048..5950e9f6 100644
--- a/XplorePlane/Models/CncModels.cs
+++ b/XplorePlane/Models/CncModels.cs
@@ -19,7 +19,12 @@ namespace XplorePlane.Models
PauseDialog,
WaitDelay,
CompleteProgram,
- Reset
+ Reset,
+ // ── 硬件控制节点(P0)| Hardware control nodes ──
+ RaySourceControl,
+ DetectorCorrection,
+ DoorControl,
+ Geometry
}
// ── CNC 节点基类与派生类型 | CNC Node Base & Derived Types ────────
@@ -38,6 +43,10 @@ namespace XplorePlane.Models
[JsonDerivedType(typeof(WaitDelayNode), "WaitDelay")]
[JsonDerivedType(typeof(CompleteProgramNode), "CompleteProgram")]
[JsonDerivedType(typeof(ResetNode), "Reset")]
+ [JsonDerivedType(typeof(RaySourceControlNode), "RaySourceControl")]
+ [JsonDerivedType(typeof(DetectorCorrectionNode), "DetectorCorrection")]
+ [JsonDerivedType(typeof(DoorControlNode), "DoorControl")]
+ [JsonDerivedType(typeof(GeometryNode), "Geometry")]
public abstract record CncNode(
Guid Id,
int Index,
@@ -90,7 +99,8 @@ namespace XplorePlane.Models
MotionState MotionState,
RaySourceState RaySourceState = null,
bool SaveImage = false,
- string ManualImagePath = "") : CncNode(Id, Index, CncNodeType.SavePosition, Name);
+ string ManualImagePath = "",
+ DetectorAcquisitionSnapshot DetectorAcq = null) : CncNode(Id, Index, CncNodeType.SavePosition, Name);
/// 检测模块节点 | Inspection module node
public record InspectionModuleNode(
@@ -135,6 +145,103 @@ namespace XplorePlane.Models
int Index,
string Name) : CncNode(Id, Index, CncNodeType.Reset, Name);
+ // ── 硬件控制节点(P0)| Hardware Control Nodes ────────────────────
+
+ /// 探测器可下发采集参数快照(用于位置节点还原)| Detector down-loadable acquisition parameters snapshot (for position node restore)
+ public record DetectorAcquisitionSnapshot(
+ int BinningIndex,
+ int Pga,
+ decimal FrameRate);
+
+ /// 射线源动作 | X-ray source action
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum RaySourceAction
+ {
+ /// 开射线 | Turn on X-ray
+ TurnOn,
+ /// 关射线 | Turn off X-ray
+ TurnOff,
+ /// 设置参数(电压+电流,不改变开关状态)| Set voltage/current without changing on/off state
+ SetParameters,
+ /// 暖机 | Warm-up
+ WarmUp,
+ /// 训机 | Training
+ Training
+ }
+
+ /// 射线源控制节点 | X-ray source control node
+ public record RaySourceControlNode(
+ Guid Id,
+ int Index,
+ string Name,
+ RaySourceAction Action,
+ double Voltage, // 电压(kV)| Voltage (kV)
+ double Current, // 电流(μA)| Current (μA)
+ bool WaitForStable = true,
+ int StableTimeoutMs = 10000) : CncNode(Id, Index, CncNodeType.RaySourceControl, Name);
+
+ /// 探测器校正类型 | Detector correction type
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum DetectorCorrectionType
+ {
+ /// 暗场校正 | Dark field correction
+ Dark,
+ /// 增益(亮场)校正 | Gain (bright field) correction
+ Gain,
+ /// 坏像素校正 | Bad pixel correction
+ BadPixel,
+ /// 自动校正(暗场+增益+坏像素)| Auto correction (dark + gain + bad pixel)
+ Auto
+ }
+
+ /// 探测器校正节点 | Detector correction node
+ public record DetectorCorrectionNode(
+ Guid Id,
+ int Index,
+ string Name,
+ DetectorCorrectionType CorrectionType,
+ int FrameCount = 10) : CncNode(Id, Index, CncNodeType.DetectorCorrection, Name);
+
+ /// 安全门动作 | Safety door action
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum DoorAction
+ {
+ /// 开门 | Open door
+ Open,
+ /// 关门 | Close door
+ Close
+ }
+
+ /// 安全门控制节点 | Safety door control node
+ public record DoorControlNode(
+ Guid Id,
+ int Index,
+ string Name,
+ DoorAction Action,
+ bool WaitForComplete = true,
+ int TimeoutMs = 15000) : CncNode(Id, Index, CncNodeType.DoorControl, Name);
+
+ /// 几何设置模式 | Geometry setup mode
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum GeometryMode
+ {
+ /// 由 FOD + FDD 反算 | Inverse from FOD + FDD
+ ByFdd,
+ /// 由 FOD + 放大倍率反算 | Inverse from FOD + magnification
+ ByMagnification
+ }
+
+ /// 几何设置节点 | Geometry setup node
+ public record GeometryNode(
+ Guid Id,
+ int Index,
+ string Name,
+ GeometryMode Mode,
+ double Fod, // 焦点-物体距离(mm)| Focus-object distance (mm)
+ double Fdd = 0, // 焦点-探测器距离(mm)| Focus-detector distance (mm)
+ double Magnification = 0,
+ bool WaitForSettled = true) : CncNode(Id, Index, CncNodeType.Geometry, Name);
+
// ── CNC 程序 | CNC Program ────────────────────────────────────────
/// CNC 程序(不可变)| CNC program (immutable)
diff --git a/XplorePlane/Models/InspectionResultModels.cs b/XplorePlane/Models/InspectionResultModels.cs
index 3482f161..940594b3 100644
--- a/XplorePlane/Models/InspectionResultModels.cs
+++ b/XplorePlane/Models/InspectionResultModels.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Text.Json.Serialization;
namespace XplorePlane.Models
{
@@ -130,6 +131,12 @@ namespace XplorePlane.Models
public long DurationMs { get; set; }
/// 节点分类,默认检测模块。
public InspectionNodeKind NodeKind { get; set; } = InspectionNodeKind.Inspection;
+
+ ///
+ /// 节点执行/采集时的硬件状态。用于 manifest 的 PositionStates 段,不直接写入 Nodes 段。
+ ///
+ [JsonIgnore]
+ public InspectionPositionStateRecord PositionState { get; set; }
}
public class InspectionMetricResult
@@ -218,10 +225,14 @@ namespace XplorePlane.Models
{
///
/// manifest 顶层架构版本。读取端默认按 1 处理(兼容历史 manifest);
- /// 写入端在 WriteManifest 时强制置为 2(含 map 字段 / Marks / Batch)。
+ /// 写入端在 WriteManifest 时强制置为 2(含 map 字段 / Map 摘要 / Marks / Batch)。
///
public int SchemaVersion { get; set; } = 1;
public InspectionRunRecord Run { get; set; } = new();
+ ///
+ /// 检查图定位摘要。manifest 是 CNC 运行总账;Map 指向可打开/可编辑的 .xpmap 标记图。
+ ///
+ public InspectionMapManifestSummary Map { get; set; }
public IReadOnlyList Nodes { get; set; } = Array.Empty();
public IReadOnlyList Metrics { get; set; } = Array.Empty();
public IReadOnlyList Assets { get; set; } = Array.Empty();
@@ -233,6 +244,12 @@ namespace XplorePlane.Models
///
public IReadOnlyList Marks { get; set; } = Array.Empty();
+ ///
+ /// 每个检测/采图位置的硬件状态归档。manifest 是追溯总账,位置状态放这里;
+ /// .xpmap 只负责总览图上的可视化标记。
+ ///
+ public IReadOnlyList PositionStates { get; set; } = Array.Empty();
+
///
/// 多点采图批次统计段(由本次运行的 Capture 节点派生,语义等价原 summary.json)。
/// 无 Capture 节点时为 null。
@@ -240,6 +257,52 @@ namespace XplorePlane.Models
public InspectionBatchSummary Batch { get; set; }
}
+ ///
+ /// manifest.json 的检查图摘要:只描述 .xpmap/overview 的位置和职责,不替代 .xpmap 文档。
+ ///
+ public class InspectionMapManifestSummary
+ {
+ public Guid MapId { get; set; }
+ public int SchemaVersion { get; set; }
+ public string DocumentPath { get; set; } = string.Empty;
+ public string OverviewImagePath { get; set; } = string.Empty;
+ public string OverviewImageRole { get; set; } = "NavigationCameraOverview";
+ public string MarkRole { get; set; } = "OverviewPositionMarkers";
+ public string LinkedImageRole { get; set; } = "DetectorRegionImage";
+ public string StateSnapshotRole { get; set; } = "PerPositionHardwareStatesInManifestPositionStates";
+ public DateTime? UpdatedAtUtc { get; set; }
+ }
+
+ ///
+ /// manifest.json 的位置状态记录:绑定某个 CNC 节点,保存运动位置、射线源电压/电流和探测器状态。
+ ///
+ public class InspectionPositionStateRecord
+ {
+ public Guid RunId { get; set; }
+ public Guid NodeId { get; set; }
+ public int NodeIndex { get; set; }
+ public string NodeName { get; set; } = string.Empty;
+ public InspectionNodeKind NodeKind { get; set; } = InspectionNodeKind.Inspection;
+ public DateTime CapturedAtUtc { get; set; } = DateTime.UtcNow;
+ public MotionState Motion { get; set; } = MotionState.Default;
+ public RaySourceReading RaySource { get; set; } = new();
+ public DetectorState Detector { get; set; } = DetectorState.Default;
+ public string ImageInfo { get; set; } = string.Empty;
+ public string SourceImagePath { get; set; } = string.Empty;
+ public string ResultImagePath { get; set; } = string.Empty;
+ }
+
+ ///
+ /// 归档用射线源读数。字段名明确表达单位,避免复用 RaySourceState.Power 时产生电流/功率歧义。
+ ///
+ public class RaySourceReading
+ {
+ public bool IsOn { get; set; }
+ public double VoltageKv { get; set; }
+ public double CurrentUa { get; set; }
+ public double PowerW { get; set; }
+ }
+
///
/// 多点采图批次统计(manifest.json 的 Batch 段)。由 NodeKind=Capture 的节点派生,
/// 语义与原 summary.json 等价。
diff --git a/XplorePlane/Services/Cnc/CncExecutionService.cs b/XplorePlane/Services/Cnc/CncExecutionService.cs
index 8be47558..a4bbf0e7 100644
--- a/XplorePlane/Services/Cnc/CncExecutionService.cs
+++ b/XplorePlane/Services/Cnc/CncExecutionService.cs
@@ -12,6 +12,8 @@ using System.Windows.Media.Imaging;
using Prism.Events;
using XP.Common.Converters;
using XP.Common.Logging.Interfaces;
+using XP.Hardware.Detector.Abstractions;
+using XP.Hardware.Detector.Services;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Services;
@@ -48,6 +50,8 @@ namespace XplorePlane.Services.Cnc
private readonly IMotionControlService _motionControlService; // 运动控制(可选)
private readonly IMotionSystem _motionSystem; // 运动系统状态查询(可选)
private readonly IRaySourceService _raySourceService; // 射线源控制(可选)
+ private readonly IDetectorService _detectorService; // 探测器控制(可选)
+ private readonly ICncInteractionService _interactionService; // 执行期人机交互(可选)
// 当前执行的取消令牌源(volatile 保证跨线程可见性)
// 探测器断连事件通过此字段取消正在执行的程序
@@ -65,7 +69,9 @@ namespace XplorePlane.Services.Cnc
IMotionControlService motionControlService = null,
IMotionSystem motionSystem = null,
IRaySourceService raySourceService = null,
- CncInspectionMarkAdapter cncMarkAdapter = null)
+ CncInspectionMarkAdapter cncMarkAdapter = null,
+ IDetectorService detectorService = null,
+ ICncInteractionService interactionService = null)
{
_archive = archive ?? throw new ArgumentNullException(nameof(archive));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -77,6 +83,8 @@ namespace XplorePlane.Services.Cnc
_motionControlService = motionControlService;
_motionSystem = motionSystem;
_raySourceService = raySourceService;
+ _detectorService = detectorService;
+ _interactionService = interactionService;
_cncMarkAdapter = cncMarkAdapter ?? new CncInspectionMarkAdapter();
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
@@ -192,6 +200,16 @@ namespace XplorePlane.Services.Cnc
}
await WaitForAxesSettledAsync(linkedCts.Token);
+ // ── 还原该位置保存的硬件状态(射线源电压/电流 + 探测器采集参数)──
+ var restoreOutcome = await RestoreHardwareStateAsync(sp, linkedCts.Token);
+ if (restoreOutcome == RestoreOutcome.AbortPaused)
+ {
+ // 射线未开被放弃 或 还原失败:暂停并终止运行,不采集不检测
+ cancelled = true;
+ positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Paused", ErrorMessage = "硬件状态还原中止" });
+ break;
+ }
+
BitmapSource positionImage = null;
if (!string.IsNullOrEmpty(sp.ManualImagePath))
{
@@ -275,6 +293,23 @@ namespace XplorePlane.Services.Cnc
catch (OperationCanceledException) { cancelled = true; }
break;
+ // ── 硬件动作节点(P0 补充)──
+ case RaySourceControlNode rc:
+ nodeSucceeded = await ExecuteRaySourceControlAsync(rc, linkedCts.Token);
+ break;
+
+ case DetectorCorrectionNode dc:
+ nodeSucceeded = await ExecuteDetectorCorrectionAsync(dc, linkedCts.Token);
+ break;
+
+ case DoorControlNode door:
+ nodeSucceeded = await ExecuteDoorControlAsync(door, linkedCts.Token);
+ break;
+
+ case GeometryNode geo:
+ nodeSucceeded = await ExecuteGeometryAsync(geo, linkedCts.Token);
+ break;
+
case CompleteProgramNode:
progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Succeeded));
goto endLoop;
@@ -415,6 +450,276 @@ namespace XplorePlane.Services.Cnc
await WaitForAxesSettledAsync(ct);
}
+ // ── 位置节点硬件状态还原 | Position node hardware state restore ──────
+
+ /// 硬件状态还原结果。
+ private enum RestoreOutcome
+ {
+ /// 还原成功(或部分因服务不可用而降级跳过)。
+ Ok,
+ /// 因服务不可用整体降级跳过。
+ SkippedDegraded,
+ /// 射线未开被放弃 或 还原失败:暂停并终止运行。
+ AbortPaused
+ }
+
+ ///
+ /// 还原位置节点保存的硬件状态:先处理射线未开提醒,再还原射线源电压/电流,最后还原探测器采集参数。
+ /// 遵循「不自动开关射线、先电压后电流」安全规则;服务不可用时降级跳过;下发失败时弹窗暂停并终止。
+ ///
+ private async Task RestoreHardwareStateAsync(SavePositionNode sp, CancellationToken ct)
+ {
+ // 1) 射线未开提醒(需求 1.5):快照要求射线开、当前未开 → 弹窗暂停等待操作员
+ if (sp.RaySourceState?.IsOn == true && !IsRayCurrentlyOn())
+ {
+ if (_interactionService == null)
+ {
+ _logger.ForModule().Warn(
+ "位置 '{0}' 需要射线开启但当前未开,且无交互服务,无法提醒(降级继续)", sp.Name);
+ }
+ else
+ {
+ bool proceed = await _interactionService.ConfirmRayOnAsync(sp.Name, ct);
+ if (!proceed || !IsRayCurrentlyOn())
+ {
+ _logger.ForModule().Warn("位置 '{0}' 射线仍未开启,中止该位置", sp.Name);
+ return RestoreOutcome.AbortPaused;
+ }
+ }
+ }
+
+ // 2) 还原射线源电压/电流(不改开关;先电压后电流)(需求 1.4)
+ if (sp.RaySourceState != null)
+ {
+ if (_raySourceService == null)
+ {
+ _logger.ForModule().Warn("射线源服务不可用,跳过参数还原(仿真降级)");
+ }
+ else
+ {
+ var vr = _raySourceService.SetVoltage((float)sp.RaySourceState.Voltage);
+ if (!vr.Success)
+ return await FailPauseAsync("设置电压失败", vr.ErrorMessage, ct);
+
+ // 注:SavePosition 约定 RaySourceState.Power 字段存储电流值(μA)
+ var cr = _raySourceService.SetCurrent((float)sp.RaySourceState.Power);
+ if (!cr.Success)
+ return await FailPauseAsync("设置电流失败", cr.ErrorMessage, ct);
+ }
+ }
+
+ // 3) 还原探测器采集参数(可下发参数)(需求 1.6)
+ if (sp.DetectorAcq != null)
+ {
+ if (_detectorService == null)
+ {
+ _logger.ForModule().Warn("探测器服务不可用,跳过参数还原(仿真降级)");
+ }
+ else
+ {
+ var dr = await _detectorService.ApplyParametersAsync(
+ sp.DetectorAcq.BinningIndex, sp.DetectorAcq.Pga, sp.DetectorAcq.FrameRate, ct);
+ if (!dr.IsSuccess)
+ return await FailPauseAsync("应用探测器参数失败", dr.ErrorMessage, ct);
+ }
+ }
+
+ return RestoreOutcome.Ok;
+ }
+
+ /// 读取当前射线是否开启(服务不可用/异常时视为未开)。
+ private bool IsRayCurrentlyOn()
+ {
+ try { return _raySourceService?.IsXRayOn ?? false; }
+ catch { return false; }
+ }
+
+ /// 还原下发失败:记录警告、弹窗提示并返回暂停终止。
+ private async Task FailPauseAsync(string title, string detail, CancellationToken ct)
+ {
+ _logger.ForModule().Warn("硬件状态还原失败:{0} - {1}", title, detail);
+ if (_interactionService != null)
+ await _interactionService.NotifyRestoreFailureAsync(title, detail ?? string.Empty, ct);
+ return RestoreOutcome.AbortPaused;
+ }
+
+ // ── 硬件动作节点执行 | Hardware action node execution ─────────────────
+
+ /// 执行射线源控制节点(开/关/设参/暖机/训机)。服务不可用时降级跳过,失败返回 false。
+ private async Task ExecuteRaySourceControlAsync(RaySourceControlNode node, CancellationToken ct)
+ {
+ if (_raySourceService == null)
+ {
+ _logger.ForModule().Warn("射线源服务不可用,射线源控制节点跳过(仿真降级)");
+ return true;
+ }
+
+ var log = _logger.ForModule();
+ switch (node.Action)
+ {
+ case RaySourceAction.SetParameters:
+ case RaySourceAction.TurnOn:
+ {
+ // 安全规则:先电压后电流;开启态不调压调流由射线源服务内部约束
+ var vr = _raySourceService.SetVoltage((float)node.Voltage);
+ if (!vr.Success) { log.Warn("设置电压失败:{0}", vr.ErrorMessage); return false; }
+ var cr = _raySourceService.SetCurrent((float)node.Current);
+ if (!cr.Success) { log.Warn("设置电流失败:{0}", cr.ErrorMessage); return false; }
+
+ if (node.Action == RaySourceAction.TurnOn)
+ {
+ var on = _raySourceService.TurnOn();
+ if (!on.Success) { log.Warn("开射线失败:{0}", on.ErrorMessage); return false; }
+ if (node.WaitForStable)
+ await WaitForRayStableAsync(node.Voltage, node.Current, node.StableTimeoutMs, ct);
+ }
+ return true;
+ }
+ case RaySourceAction.TurnOff:
+ {
+ var off = _raySourceService.TurnOff();
+ if (!off.Success) { log.Warn("关射线失败:{0}", off.ErrorMessage); return false; }
+ return true;
+ }
+ case RaySourceAction.WarmUp:
+ {
+ var r = _raySourceService.WarmUp();
+ if (!r.Success) { log.Warn("暖机失败:{0}", r.ErrorMessage); return false; }
+ return true;
+ }
+ case RaySourceAction.Training:
+ {
+ var r = _raySourceService.Training();
+ if (!r.Success) { log.Warn("训机失败:{0}", r.ErrorMessage); return false; }
+ return true;
+ }
+ default:
+ return true;
+ }
+ }
+
+ /// 轮询实际电压/电流直到进入目标误差阈值或超时。
+ private async Task WaitForRayStableAsync(double targetV, double targetC, int timeoutMs, CancellationToken ct)
+ {
+ var sw = Stopwatch.StartNew();
+ const int pollMs = 200;
+ const double vTol = 2.0; // kV
+ const double cTol = 20.0; // μA
+
+ while (sw.ElapsedMilliseconds < timeoutMs)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ double v = 0, c = 0;
+ var vr = _raySourceService.ReadVoltage();
+ if (vr?.Success == true) v = vr.GetFloat();
+ var cr = _raySourceService.ReadCurrent();
+ if (cr?.Success == true) c = cr.GetFloat();
+
+ if (Math.Abs(v - targetV) <= vTol && Math.Abs(c - targetC) <= cTol)
+ {
+ _logger.ForModule().Info("射线已稳定,耗时 {0}ms", sw.ElapsedMilliseconds);
+ return;
+ }
+
+ await Task.Delay(pollMs, ct);
+ }
+
+ _logger.ForModule().Warn("等待射线稳定超时({0}ms),继续执行", timeoutMs);
+ }
+
+ /// 执行探测器校正节点。服务不可用时降级跳过,失败返回 false。
+ private async Task ExecuteDetectorCorrectionAsync(DetectorCorrectionNode node, CancellationToken ct)
+ {
+ if (_detectorService == null)
+ {
+ _logger.ForModule().Warn("探测器服务不可用,校正节点跳过(仿真降级)");
+ return true;
+ }
+
+ DetectorResult result = node.CorrectionType switch
+ {
+ DetectorCorrectionType.Dark => await _detectorService.DarkCorrectionAsync(node.FrameCount, ct),
+ DetectorCorrectionType.Gain => await _detectorService.GainCorrectionAsync(node.FrameCount, ct),
+ DetectorCorrectionType.BadPixel => await _detectorService.BadPixelCorrectionAsync(ct),
+ DetectorCorrectionType.Auto => await _detectorService.AutoCorrectionAsync(node.FrameCount, ct),
+ _ => DetectorResult.Success("无操作")
+ };
+
+ if (!result.IsSuccess)
+ {
+ _logger.ForModule().Warn("探测器校正失败:{0}", result.ErrorMessage);
+ return false;
+ }
+ return true;
+ }
+
+ /// 执行安全门控制节点。服务不可用时降级跳过,失败返回 false。
+ private async Task ExecuteDoorControlAsync(DoorControlNode node, CancellationToken ct)
+ {
+ if (_motionControlService == null)
+ {
+ _logger.ForModule().Warn("运动服务不可用,门控节点跳过(仿真降级)");
+ return true;
+ }
+
+ // OpenDoor 内含联锁检查:联锁有效时会被拒绝
+ var result = node.Action == DoorAction.Open
+ ? _motionControlService.OpenDoor()
+ : _motionControlService.CloseDoor();
+
+ if (!result.Success)
+ {
+ _logger.ForModule().Warn("门控指令失败:{0}", result.ErrorMessage);
+ return false;
+ }
+
+ if (node.WaitForComplete)
+ await WaitForDoorSettledAsync(node, ct);
+ return true;
+ }
+
+ ///
+ /// 等待门到位。
+ /// TODO(待硬件层完善):当前运动层未暴露门状态信号的可读到位判定,
+ /// 暂以可取消的短暂等待占位,硬件层补齐门状态后应改为轮询到位/超时。
+ ///
+ private async Task WaitForDoorSettledAsync(DoorControlNode node, CancellationToken ct)
+ {
+ try
+ {
+ // 保守等待,给物理门动作留出时间;上限不超过节点配置超时
+ int waitMs = Math.Min(node.TimeoutMs, 2000);
+ await Task.Delay(waitMs, ct);
+ }
+ catch (OperationCanceledException) { throw; }
+ }
+
+ /// 执行几何设置节点。服务不可用时降级跳过,失败返回 false。
+ private async Task ExecuteGeometryAsync(GeometryNode node, CancellationToken ct)
+ {
+ if (_motionControlService == null)
+ {
+ _logger.ForModule().Warn("运动服务不可用,几何节点跳过(仿真降级)");
+ return true;
+ }
+
+ var result = node.Mode == GeometryMode.ByFdd
+ ? _motionControlService.ApplyGeometry(node.Fod, node.Fdd)
+ : _motionControlService.ApplyGeometryByMagnification(node.Fod, node.Magnification);
+
+ if (!result.Success)
+ {
+ _logger.ForModule().Warn("几何设置失败:{0}", result.ErrorMessage);
+ return false;
+ }
+
+ if (node.WaitForSettled)
+ await WaitForAxesSettledAsync(ct);
+ return true;
+ }
+
+
///
/// 将所有轴移动到目标位置。
/// MotionState 中的坐标单位为微米(μm),运动控制接口使用毫米(mm),需除以 1000。
@@ -425,7 +730,6 @@ namespace XplorePlane.Services.Cnc
{
if (_motionControlService == null)
return Task.FromResult(MotionResult.Ok());
-
// Linear axes: convert μm → mm (divide by 1000.0)
var stageXResult = _motionControlService.MoveToTarget(AxisId.StageX, target.StageX / 1000.0);
if (!stageXResult.Success) return Task.FromResult(stageXResult);
@@ -602,7 +906,9 @@ namespace XplorePlane.Services.Cnc
NodeId = inspectionNode.Id,
NodeIndex = inspectionNode.Index,
NodeName = inspectionNode.Name,
- PipelineName = inspectionNode.Pipeline?.Name ?? string.Empty
+ PipelineName = inspectionNode.Pipeline?.Name ?? string.Empty,
+ NodeKind = InspectionNodeKind.Inspection,
+ PositionState = CreatePositionState(runId, inspectionNode, sourceImage)
};
PipelineExecutionSnapshot pipelineSnapshot = null;
@@ -733,6 +1039,75 @@ namespace XplorePlane.Services.Cnc
return resultImage;
}
+ private InspectionPositionStateRecord CreatePositionState(Guid runId, InspectionModuleNode node, BitmapSource sourceImage)
+ {
+ try
+ {
+ var motion = _appStateService?.MotionState ?? MotionState.Default;
+ var ray = _appStateService?.RaySourceState ?? RaySourceState.Default;
+ var detector = _appStateService?.DetectorState ?? DetectorState.Default;
+ var currentUa = TryReadRayCurrent();
+ var powerW = ray.Power;
+ if (powerW <= 0 && ray.Voltage > 0 && currentUa > 0)
+ {
+ powerW = ray.Voltage * currentUa / 1000.0;
+ }
+
+ return new InspectionPositionStateRecord
+ {
+ RunId = runId,
+ NodeId = node.Id,
+ NodeIndex = node.Index,
+ NodeName = node.Name,
+ NodeKind = InspectionNodeKind.Inspection,
+ CapturedAtUtc = DateTime.UtcNow,
+ Motion = motion,
+ RaySource = new RaySourceReading
+ {
+ IsOn = ray.IsOn,
+ VoltageKv = ray.Voltage,
+ CurrentUa = currentUa,
+ PowerW = powerW
+ },
+ Detector = detector,
+ ImageInfo = sourceImage == null ? string.Empty : $"{sourceImage.PixelWidth}x{sourceImage.PixelHeight}"
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.ForModule().Warn("采集 CNC 位置状态失败,manifest 将写入默认状态:{0}", ex.Message);
+ return new InspectionPositionStateRecord
+ {
+ RunId = runId,
+ NodeId = node.Id,
+ NodeIndex = node.Index,
+ NodeName = node.Name,
+ NodeKind = InspectionNodeKind.Inspection,
+ CapturedAtUtc = DateTime.UtcNow,
+ ImageInfo = sourceImage == null ? string.Empty : $"{sourceImage.PixelWidth}x{sourceImage.PixelHeight}"
+ };
+ }
+ }
+
+ private double TryReadRayCurrent()
+ {
+ try
+ {
+ var result = _raySourceService?.ReadCurrent();
+ if (result?.Success == true)
+ {
+ return result.GetFloat();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ForModule().Warn("读取射线源电流失败,manifest 位置状态将尝试由功率估算:{0}", ex.Message);
+ }
+
+ var ray = _appStateService?.RaySourceState ?? RaySourceState.Default;
+ return ray.Voltage > 0 ? ray.Power * 1000.0 / ray.Voltage : 0d;
+ }
+
///
/// 从流水线最后一步的 OutputData 中提取检测指标(BGA空洞率、孔隙测量等)。
/// 写入 manifest.json 的 Metrics 部分。
diff --git a/XplorePlane/Services/Cnc/CncInteractionService.cs b/XplorePlane/Services/Cnc/CncInteractionService.cs
new file mode 100644
index 00000000..2adc61ec
--- /dev/null
+++ b/XplorePlane/Services/Cnc/CncInteractionService.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using XP.Common.Logging.Interfaces;
+
+namespace XplorePlane.Services.Cnc
+{
+ ///
+ /// 的 WPF 实现,基于 Dispatcher + MessageBox 实现模态暂停。
+ /// 沿用 PauseDialogNode 的模态对话框模式。
+ ///
+ public class CncInteractionService : ICncInteractionService
+ {
+ private readonly ILoggerService _logger;
+
+ public CncInteractionService(ILoggerService logger = null)
+ {
+ _logger = logger?.ForModule();
+ }
+
+ ///
+ public async Task ConfirmRayOnAsync(string positionName, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null)
+ {
+ // 无 UI 环境(如测试/无头运行):保守放弃,避免静默采集
+ _logger?.Warn("[CncInteraction] 无 Dispatcher,射线未开确认默认放弃");
+ return false;
+ }
+
+ _logger?.Warn("[CncInteraction] 位置 '{0}' 需要射线开启但当前未开,弹窗提醒操作员", positionName);
+
+ var result = await dispatcher.InvokeAsync(() =>
+ MessageBox.Show(
+ $"检测位置「{positionName}」需要射线开启,但当前射线未开。\n\n请手动开启射线后点击「确定」继续,或点击「取消」停止程序。",
+ "射线未开启",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Warning));
+
+ return result == MessageBoxResult.OK;
+ }
+
+ ///
+ public async Task NotifyRestoreFailureAsync(string title, string detail, CancellationToken cancellationToken)
+ {
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null)
+ {
+ _logger?.Warn("[CncInteraction] 硬件还原失败(无 UI):{0} - {1}", title, detail);
+ return;
+ }
+
+ _logger?.Warn("[CncInteraction] 硬件还原失败:{0} - {1}", title, detail);
+
+ await dispatcher.InvokeAsync(() =>
+ MessageBox.Show(
+ $"{detail}\n\n程序已暂停,请处理后重新运行。",
+ title,
+ MessageBoxButton.OK,
+ MessageBoxImage.Error));
+ }
+ }
+}
diff --git a/XplorePlane/Services/Cnc/CncProgramService.cs b/XplorePlane/Services/Cnc/CncProgramService.cs
index 9ab3234d..dcb4c026 100644
--- a/XplorePlane/Services/Cnc/CncProgramService.cs
+++ b/XplorePlane/Services/Cnc/CncProgramService.cs
@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
+using XP.Hardware.Detector.Services;
using XP.Hardware.RaySource.Services;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
@@ -26,6 +27,7 @@ namespace XplorePlane.Services.Cnc
private readonly IRaySourceService _raySourceService;
private readonly ILoggerService _logger;
private readonly IPermissionService _permissionService;
+ private readonly IDetectorService _detectorService;
// ── 序列化配置 | Serialization options ──
private static readonly JsonSerializerOptions CncJsonOptions = new()
@@ -39,7 +41,8 @@ namespace XplorePlane.Services.Cnc
IAppStateService appStateService,
IRaySourceService raySourceService,
ILoggerService logger,
- IPermissionService permissionService)
+ IPermissionService permissionService,
+ IDetectorService detectorService = null)
{
ArgumentNullException.ThrowIfNull(appStateService);
ArgumentNullException.ThrowIfNull(raySourceService);
@@ -50,6 +53,7 @@ namespace XplorePlane.Services.Cnc
_raySourceService = raySourceService;
_logger = logger.ForModule();
_permissionService = permissionService;
+ _detectorService = detectorService; // 可选:用于捕获探测器采集参数快照
_logger.Info("CncProgramService 已初始化 | CncProgramService initialized");
}
@@ -129,6 +133,22 @@ namespace XplorePlane.Services.Cnc
CncNodeType.Reset => new ResetNode(
id, defaultIndex, "复位"),
+ // 射线源控制:默认设参数,电压/电流取当前值 | Ray source control: default SetParameters
+ CncNodeType.RaySourceControl => CreateRaySourceControlNode(id, defaultIndex),
+
+ // 探测器校正:默认自动校正、10 帧 | Detector correction: default Auto, 10 frames
+ CncNodeType.DetectorCorrection => new DetectorCorrectionNode(
+ id, defaultIndex, $"探测器校正_{defaultIndex}",
+ CorrectionType: DetectorCorrectionType.Auto, FrameCount: 10),
+
+ // 安全门:默认开门 | Door control: default Open
+ CncNodeType.DoorControl => new DoorControlNode(
+ id, defaultIndex, $"安全门_{defaultIndex}",
+ Action: DoorAction.Open),
+
+ // 几何设置:默认按 FDD,取当前几何值 | Geometry: default ByFdd
+ CncNodeType.Geometry => CreateGeometryNode(id, defaultIndex),
+
_ => throw new ArgumentOutOfRangeException(nameof(type), type, $"不支持的节点类型 | Unsupported node type: {type}")
};
@@ -379,6 +399,12 @@ namespace XplorePlane.Services.Cnc
/// 等待延时最大值(毫秒)| Wait delay maximum (ms)
private const int WaitDelayMax = 300000;
+ // ── 硬件节点值域常量 | Hardware node value-range constants ──
+ private const int FrameCountMin = 1;
+ private const int FrameCountMax = 1000;
+ private const int TimeoutMsMin = 0;
+ private const int TimeoutMsMax = 600000;
+
// ── 内部辅助方法 | Internal helper methods ──
///
@@ -388,22 +414,43 @@ namespace XplorePlane.Services.Cnc
Math.Clamp(value, WaitDelayMin, WaitDelayMax);
///
- /// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断)
- /// Apply value range validation to deserialized nodes (e.g. WaitDelay clamping)
+ /// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断、硬件节点参数下限)
+ /// Apply value range validation to deserialized nodes.
///
private static IReadOnlyList ValidateNodes(IReadOnlyList nodes)
{
var result = new List(nodes.Count);
foreach (var node in nodes)
{
- if (node is WaitDelayNode wdn)
+ switch (node)
{
- var clamped = ClampWaitDelay(wdn.DelayMilliseconds);
- result.Add(clamped != wdn.DelayMilliseconds ? wdn with { DelayMilliseconds = clamped } : wdn);
- }
- else
- {
- result.Add(node);
+ case WaitDelayNode wdn:
+ {
+ var clamped = ClampWaitDelay(wdn.DelayMilliseconds);
+ result.Add(clamped != wdn.DelayMilliseconds ? wdn with { DelayMilliseconds = clamped } : wdn);
+ break;
+ }
+ case DetectorCorrectionNode dcn:
+ {
+ var clamped = Math.Clamp(dcn.FrameCount, FrameCountMin, FrameCountMax);
+ result.Add(clamped != dcn.FrameCount ? dcn with { FrameCount = clamped } : dcn);
+ break;
+ }
+ case RaySourceControlNode rcn:
+ {
+ var clamped = Math.Clamp(rcn.StableTimeoutMs, TimeoutMsMin, TimeoutMsMax);
+ result.Add(clamped != rcn.StableTimeoutMs ? rcn with { StableTimeoutMs = clamped } : rcn);
+ break;
+ }
+ case DoorControlNode dcn2:
+ {
+ var clamped = Math.Clamp(dcn2.TimeoutMs, TimeoutMsMin, TimeoutMsMax);
+ result.Add(clamped != dcn2.TimeoutMs ? dcn2 with { TimeoutMs = clamped } : dcn2);
+ break;
+ }
+ default:
+ result.Add(node);
+ break;
}
}
return result.AsReadOnly();
@@ -485,11 +532,46 @@ namespace XplorePlane.Services.Cnc
double current = Math.Round(TryReadCurrent(), 0);
var saveRayState = new RaySourceState(rayState.IsOn, Math.Round(rayState.Voltage, 0), current);
+ // 捕获探测器采集参数快照(Binning/PGA/帧率)。
+ // 待硬件层完善:GetCurrentAcquisitionParameters 目前返回 null,此时 DetectorAcq 为 null,
+ // 执行时将跳过探测器参数还原,不影响运动与射线源还原。
+ DetectorAcquisitionSnapshot detectorAcq = null;
+ var acqParams = _detectorService?.GetCurrentAcquisitionParameters();
+ if (acqParams != null)
+ detectorAcq = new DetectorAcquisitionSnapshot(acqParams.BinningIndex, acqParams.Pga, acqParams.FrameRate);
+
return new SavePositionNode(
id, index, $"检测位置_{index}",
MotionState: RoundMotionState(_appStateService.MotionState),
RaySourceState: saveRayState,
- SaveImage: false);
+ SaveImage: false,
+ DetectorAcq: detectorAcq);
+ }
+
+ /// 创建射线源控制节点(默认设参数,电压/电流取当前值)| Create ray source control node
+ private RaySourceControlNode CreateRaySourceControlNode(Guid id, int index)
+ {
+ var rayState = _appStateService.RaySourceState;
+ double voltage = Math.Round(rayState?.Voltage ?? 0, 0);
+ double current = Math.Round(TryReadCurrent(), 0);
+ return new RaySourceControlNode(
+ id, index, $"射线源控制_{index}",
+ Action: RaySourceAction.SetParameters,
+ Voltage: voltage,
+ Current: current);
+ }
+
+ /// 创建几何设置节点(默认按 FDD,取当前几何值,μm→mm)| Create geometry node
+ private GeometryNode CreateGeometryNode(Guid id, int index)
+ {
+ // MotionState 中 FOD/FDD 单位为 μm,几何节点使用 mm,需除以 1000
+ var motion = _appStateService.MotionState ?? MotionState.Default;
+ return new GeometryNode(
+ id, index, $"几何设置_{index}",
+ Mode: GeometryMode.ByFdd,
+ Fod: Math.Round(motion.FOD / 1000.0, 3),
+ Fdd: Math.Round(motion.FDD / 1000.0, 3),
+ Magnification: Math.Round(motion.Magnification, 3));
}
///
diff --git a/XplorePlane/Services/Cnc/ICncInteractionService.cs b/XplorePlane/Services/Cnc/ICncInteractionService.cs
new file mode 100644
index 00000000..b060e236
--- /dev/null
+++ b/XplorePlane/Services/Cnc/ICncInteractionService.cs
@@ -0,0 +1,26 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace XplorePlane.Services.Cnc
+{
+ ///
+ /// CNC 执行期的人机交互服务,封装弹窗提醒与暂停确认,便于单元测试 Mock。
+ /// CNC execution-time interaction service, wraps dialog prompts for testability.
+ ///
+ public interface ICncInteractionService
+ {
+ ///
+ /// 提醒操作员当前位置需要射线开启但射线未开,暂停等待其处理。
+ /// 返回 true 表示操作员已确认继续(应已手动开启射线),false 表示放弃。
+ /// Prompt operator that the position requires X-ray on while it is off; blocks until confirmed.
+ /// Returns true if the operator confirms to continue, false to abort.
+ ///
+ Task ConfirmRayOnAsync(string positionName, CancellationToken cancellationToken);
+
+ ///
+ /// 提示硬件状态还原失败,暂停程序(模态提示,用户确认后返回)。
+ /// Notify that a hardware state restore failed; blocks (modal) until acknowledged.
+ ///
+ Task NotifyRestoreFailureAsync(string title, string detail, CancellationToken cancellationToken);
+ }
+}
diff --git a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs
index 16516c49..199bace4 100644
--- a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs
+++ b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS inspection_node_results (
status TEXT NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
node_kind TEXT NOT NULL DEFAULT 'Inspection',
+ position_state_json TEXT NULL,
PRIMARY KEY (run_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_inspection_nodes_run_id ON inspection_node_results(run_id);
@@ -167,10 +168,10 @@ VALUES (
private const string InsertNodeSql = @"
INSERT OR REPLACE INTO inspection_node_results (
run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash,
- node_pass, source_image_path, result_image_path, status, duration_ms, node_kind)
+ node_pass, source_image_path, result_image_path, status, duration_ms, node_kind, position_state_json)
VALUES (
@run_id, @node_id, @node_index, @node_name, @pipeline_id, @pipeline_name, @pipeline_version_hash,
- @node_pass, @source_image_path, @result_image_path, @status, @duration_ms, @node_kind)";
+ @node_pass, @source_image_path, @result_image_path, @status, @duration_ms, @node_kind, @position_state_json)";
private const string InsertMetricSql = @"
INSERT OR REPLACE INTO inspection_metric_results (
@@ -395,6 +396,17 @@ WHERE run_id = @run_id";
nodeResult.Status = assetFailureMode.Value;
}
+ if (nodeResult.PositionState != null)
+ {
+ nodeResult.PositionState.RunId = nodeResult.RunId;
+ nodeResult.PositionState.NodeId = nodeResult.NodeId;
+ nodeResult.PositionState.NodeIndex = nodeResult.NodeIndex;
+ nodeResult.PositionState.NodeName = nodeResult.NodeName;
+ nodeResult.PositionState.NodeKind = nodeResult.NodeKind;
+ nodeResult.PositionState.SourceImagePath = nodeResult.SourceImagePath;
+ nodeResult.PositionState.ResultImagePath = nodeResult.ResultImagePath;
+ }
+
var metricsList = (metrics ?? Enumerable.Empty())
.Select(metric =>
{
@@ -430,7 +442,8 @@ WHERE run_id = @run_id";
["result_image_path"] = nodeResult.ResultImagePath,
["status"] = nodeResult.Status.ToString(),
["duration_ms"] = nodeResult.DurationMs,
- ["node_kind"] = nodeResult.NodeKind.ToString()
+ ["node_kind"] = nodeResult.NodeKind.ToString(),
+ ["position_state_json"] = nodeResult.PositionState == null ? null : JsonSerializer.Serialize(nodeResult.PositionState, JsonOptions)
}).ConfigureAwait(false);
if (!saveNode.IsSuccess)
@@ -836,7 +849,7 @@ WHERE run_id = @run_id";
var run = MapRun(runs[0]);
var (nodeResult, nodeRows) = await _db.QueryListAsync(
- "SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms, node_kind FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC",
+ "SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms, node_kind, position_state_json FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC",
new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false);
var (metricResult, metricRows) = await _db.QueryListAsync(
@@ -860,14 +873,21 @@ WHERE run_id = @run_id";
var marks = await QueryMarksAsync(new InspectionMarkQuery { RunId = runId }).ConfigureAwait(false);
var events = await QueryRunEventsAsync(runId).ConfigureAwait(false);
+ var nodes = nodeRows.Select(MapNode).ToList().AsReadOnly();
+
return new InspectionRunDetail
{
Run = run,
- Nodes = nodeRows.Select(MapNode).ToList().AsReadOnly(),
+ Nodes = nodes,
Metrics = metricRows.Select(MapMetric).ToList().AsReadOnly(),
Assets = assetRows.Select(MapAsset).ToList().AsReadOnly(),
PipelineSnapshots = snapshotRows.Select(MapSnapshot).ToList().AsReadOnly(),
Marks = marks,
+ PositionStates = nodes
+ .Select(n => n.PositionState)
+ .Where(s => s != null)
+ .ToList()
+ .AsReadOnly(),
Events = events
};
}
@@ -954,6 +974,7 @@ WHERE run_id = @run_id";
("inspection_node_results", "status", "TEXT NOT NULL DEFAULT 'Pending'"),
("inspection_node_results", "duration_ms", "INTEGER NOT NULL DEFAULT 0"),
("inspection_node_results", "node_kind", "TEXT NOT NULL DEFAULT 'Inspection'"),
+ ("inspection_node_results", "position_state_json", "TEXT NULL"),
];
private async Task EnsureInitializedAsync()
@@ -1123,7 +1144,9 @@ WHERE run_id = @run_id";
{
// 由本次运行的 Capture 节点派生 Batch 段(语义等价原 summary.json)。
detail.Batch = DeriveBatchSummary(detail);
- // 写入端固定为 SchemaVersion=2(含 Run 上 map 字段 + Marks + Batch)。
+ // manifest 是运行归档总账;Map 段只做 .xpmap 标记图定位与职责说明。
+ detail.Map = DeriveMapSummary(detail.Run);
+ // 写入端固定为 SchemaVersion=2(含 Run 上 map 字段 + Map 摘要 + Marks + Batch)。
detail.SchemaVersion = 2;
var runDirectory = Path.Combine(_baseDirectory, detail.Run.ResultRootPath);
@@ -1133,6 +1156,23 @@ WHERE run_id = @run_id";
await File.WriteAllTextAsync(manifestPath, json).ConfigureAwait(false);
}
+ private static InspectionMapManifestSummary DeriveMapSummary(InspectionRunRecord run)
+ {
+ if (run?.MapId == null)
+ {
+ return null;
+ }
+
+ return new InspectionMapManifestSummary
+ {
+ MapId = run.MapId.Value,
+ SchemaVersion = run.MapSchemaVersion,
+ DocumentPath = "documentation/inspection-map.xpmap",
+ OverviewImagePath = run.OverviewImageRelativePath ?? string.Empty,
+ UpdatedAtUtc = run.MapUpdatedAt
+ };
+ }
+
///
/// 由本次运行的 Capture 节点(NodeKind=Capture)派生多点采图批次统计。
/// 无 Capture 节点时返回 null。
@@ -1379,6 +1419,19 @@ WHERE run_id = @run_id";
private static InspectionNodeResult MapNode(InspectionNodeRow row)
{
_ = Enum.TryParse(row.status, out var status);
+ InspectionPositionStateRecord positionState = null;
+ if (!string.IsNullOrWhiteSpace(row.position_state_json))
+ {
+ try
+ {
+ positionState = JsonSerializer.Deserialize(row.position_state_json, JsonOptions);
+ }
+ catch
+ {
+ positionState = null;
+ }
+ }
+
return new InspectionNodeResult
{
RunId = Guid.Parse(row.run_id),
@@ -1393,7 +1446,8 @@ WHERE run_id = @run_id";
ResultImagePath = row.result_image_path,
Status = status,
DurationMs = row.duration_ms,
- NodeKind = Enum.TryParse(row.node_kind, ignoreCase: true, out var nodeKind) ? nodeKind : InspectionNodeKind.Inspection
+ NodeKind = Enum.TryParse(row.node_kind, ignoreCase: true, out var nodeKind) ? nodeKind : InspectionNodeKind.Inspection,
+ PositionState = positionState
};
}
@@ -1498,6 +1552,7 @@ WHERE run_id = @run_id";
public string status { get; set; } = string.Empty;
public long duration_ms { get; set; }
public string node_kind { get; set; } = "Inspection";
+ public string position_state_json { get; set; } = string.Empty;
}
internal class InspectionMetricRow
diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs
index 5f5870de..b6e95c8c 100644
--- a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs
+++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs
@@ -93,6 +93,11 @@ namespace XplorePlane.ViewModels.Cnc
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram), () => !IsRunning && CanEditCncProgram);
InsertResetCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.Reset), () => !IsRunning && CanEditCncProgram);
+ InsertRaySourceControlCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.RaySourceControl), () => !IsRunning && CanEditCncProgram);
+ InsertDetectorCorrectionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.DetectorCorrection), () => !IsRunning && CanEditCncProgram);
+ InsertDoorControlCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.DoorControl), () => !IsRunning && CanEditCncProgram);
+ InsertGeometryCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.Geometry), () => !IsRunning && CanEditCncProgram);
+
DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode)
.ObservesProperty(() => SelectedNode);
MoveNodeUpCommand = new DelegateCommand(ExecuteMoveNodeUp);
@@ -216,6 +221,10 @@ namespace XplorePlane.ViewModels.Cnc
public DelegateCommand InsertWaitDelayCommand { get; }
public DelegateCommand InsertCompleteProgramCommand { get; }
public DelegateCommand InsertResetCommand { get; }
+ public DelegateCommand InsertRaySourceControlCommand { get; }
+ public DelegateCommand InsertDetectorCorrectionCommand { get; }
+ public DelegateCommand InsertDoorControlCommand { get; }
+ public DelegateCommand InsertGeometryCommand { get; }
public DelegateCommand DeleteNodeCommand { get; }
public DelegateCommand MoveNodeUpCommand { get; }
public DelegateCommand MoveNodeDownCommand { get; }
@@ -630,6 +639,10 @@ namespace XplorePlane.ViewModels.Cnc
InsertWaitDelayCommand.RaiseCanExecuteChanged();
InsertCompleteProgramCommand.RaiseCanExecuteChanged();
InsertResetCommand.RaiseCanExecuteChanged();
+ InsertRaySourceControlCommand.RaiseCanExecuteChanged();
+ InsertDetectorCorrectionCommand.RaiseCanExecuteChanged();
+ InsertDoorControlCommand.RaiseCanExecuteChanged();
+ InsertGeometryCommand.RaiseCanExecuteChanged();
DeleteNodeCommand.RaiseCanExecuteChanged();
MoveNodeUpCommand.RaiseCanExecuteChanged();
MoveNodeDownCommand.RaiseCanExecuteChanged();
diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs
index d4092a2e..ffe475f8 100644
--- a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs
+++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs
@@ -119,6 +119,10 @@ namespace XplorePlane.ViewModels.Cnc
public bool IsWaitDelay => _model is WaitDelayNode;
public bool IsCompleteProgram => _model is CompleteProgramNode;
public bool IsReset => _model is ResetNode;
+ public bool IsRaySourceControl => _model is RaySourceControlNode;
+ public bool IsDetectorCorrection => _model is DetectorCorrectionNode;
+ public bool IsDoorControl => _model is DoorControlNode;
+ public bool IsGeometry => _model is GeometryNode;
public bool IsPositionChild => _model is InspectionModuleNode or InspectionMarkerNode;
public bool IsMotionSnapshotNode => _model is ReferencePointNode or SaveNodeNode or SaveNodeWithImageNode or SavePositionNode;
public string RelationTag => _model switch
@@ -535,6 +539,10 @@ namespace XplorePlane.ViewModels.Cnc
CncNodeType.WaitDelay => "/Assets/Icons/wait.png",
CncNodeType.CompleteProgram => "/Assets/Icons/finish.png",
CncNodeType.Reset => "/Assets/Icons/Home.png",
+ CncNodeType.RaySourceControl => "/Assets/Icons/reference.png",
+ CncNodeType.DetectorCorrection => "/Assets/Icons/Module.png",
+ CncNodeType.DoorControl => "/Assets/Icons/message.png",
+ CncNodeType.Geometry => "/Assets/Icons/add-pos.png",
_ => "/Assets/Icons/cnc.png",
};
}
@@ -695,6 +703,10 @@ namespace XplorePlane.ViewModels.Cnc
RaisePropertyChanged(nameof(IsWaitDelay));
RaisePropertyChanged(nameof(IsCompleteProgram));
RaisePropertyChanged(nameof(IsReset));
+ RaisePropertyChanged(nameof(IsRaySourceControl));
+ RaisePropertyChanged(nameof(IsDetectorCorrection));
+ RaisePropertyChanged(nameof(IsDoorControl));
+ RaisePropertyChanged(nameof(IsGeometry));
RaisePropertyChanged(nameof(IsPositionChild));
RaisePropertyChanged(nameof(IsMotionSnapshotNode));
RaisePropertyChanged(nameof(RelationTag));
diff --git a/XplorePlane/ViewModels/InspectionDocumentation/InspectionMapViewModel.cs b/XplorePlane/ViewModels/InspectionDocumentation/InspectionMapViewModel.cs
index 71e6563b..7eb2357e 100644
--- a/XplorePlane/ViewModels/InspectionDocumentation/InspectionMapViewModel.cs
+++ b/XplorePlane/ViewModels/InspectionDocumentation/InspectionMapViewModel.cs
@@ -3,12 +3,15 @@ using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
+using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Media.Imaging;
using XP.Common.GeneralForm.Views;
using XplorePlane.Models;
using XplorePlane.Services.InspectionDocumentation;
+using XplorePlane.Services.MainViewport;
namespace XplorePlane.ViewModels.InspectionDocumentation
{
@@ -19,6 +22,7 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
private readonly IInspectionReportGenerator _reportGenerator;
private readonly IInspectionRunDocumentationContext _runContext;
private readonly ITraceabilityService _traceabilityService;
+ private readonly IMainViewportService _mainViewportService;
private InspectionMapDocument? _currentMap;
private InspectionMarkRecord? _selectedMark;
private InspectionMarkType _selectedMarkType = InspectionMarkType.Pass;
@@ -32,13 +36,15 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
IInspectionMapStore mapStore,
IInspectionReportGenerator reportGenerator,
IInspectionRunDocumentationContext runContext,
- ITraceabilityService traceabilityService)
+ ITraceabilityService traceabilityService,
+ IMainViewportService mainViewportService)
{
_documentationService = documentationService ?? throw new ArgumentNullException(nameof(documentationService));
_mapStore = mapStore ?? throw new ArgumentNullException(nameof(mapStore));
_reportGenerator = reportGenerator ?? throw new ArgumentNullException(nameof(reportGenerator));
_runContext = runContext ?? throw new ArgumentNullException(nameof(runContext));
_traceabilityService = traceabilityService ?? throw new ArgumentNullException(nameof(traceabilityService));
+ _mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
NewMapCommand = new DelegateCommand(ExecuteNewMap);
SaveMapCommand = new DelegateCommand(async () => await ExecuteSaveMapAsync(), () => CurrentMap != null);
@@ -219,7 +225,10 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
CurrentMap = result.Document;
TraceabilityId = CurrentMap.TraceabilityId;
_runContext.UseRun(CurrentMap.RunId, CurrentMap.ResultRootPath);
- StatusText = result.Warnings.Count == 0 ? "检查图已加载" : $"检查图已加载,{result.Warnings.Count} 个资产警告";
+ var overviewLoaded = LoadOverviewIntoViewport(CurrentMap);
+ StatusText = result.Warnings.Count == 0
+ ? overviewLoaded ? "检查图已加载,已显示总览图" : "检查图已加载,但总览图未找到"
+ : overviewLoaded ? $"检查图已加载,已显示总览图,{result.Warnings.Count} 个资产警告" : $"检查图已加载,{result.Warnings.Count} 个资产警告";
}
catch (Exception ex)
{
@@ -228,6 +237,37 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
}
}
+ private bool LoadOverviewIntoViewport(InspectionMapDocument map)
+ {
+ if (map == null || string.IsNullOrWhiteSpace(map.OverviewImageRelativePath))
+ return false;
+
+ try
+ {
+ var relative = map.OverviewImageRelativePath
+ .Replace('/', Path.DirectorySeparatorChar)
+ .Replace('\\', Path.DirectorySeparatorChar);
+ var overviewPath = Path.Combine(_mapStore.GetRunRootDirectory(map), relative);
+ if (!File.Exists(overviewPath))
+ return false;
+
+ var image = new BitmapImage();
+ image.BeginInit();
+ image.UriSource = new Uri(overviewPath, UriKind.Absolute);
+ image.CacheOption = BitmapCacheOption.OnLoad;
+ image.EndInit();
+ image.Freeze();
+
+ _mainViewportService.SetManualImage(image, overviewPath);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"加载检查图总览图失败:{ex.Message}";
+ return false;
+ }
+ }
+
private async Task ExecuteSaveReportAsync()
{
if (CurrentMap == null)
diff --git a/XplorePlane/ViewModels/Setting/SettingsViewModel.cs b/XplorePlane/ViewModels/Setting/SettingsViewModel.cs
index 12ba3148..a0810832 100644
--- a/XplorePlane/ViewModels/Setting/SettingsViewModel.cs
+++ b/XplorePlane/ViewModels/Setting/SettingsViewModel.cs
@@ -8,6 +8,7 @@ using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.Permission;
+using XplorePlane.Services.Storage;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.ViewModels.Setting
@@ -17,10 +18,13 @@ namespace XplorePlane.ViewModels.Setting
private readonly ILoggerService _logger;
private readonly IPermissionService _permissionService;
private readonly IEventAggregator _eventAggregator;
+ private readonly IXpDataPathService _dataPathService;
public DelegateCommand SaveCommand { get; }
public DelegateCommand CancelCommand { get; }
public DelegateCommand ResetToDefaultCommand { get; }
+ public DelegateCommand BrowseDataRootPathCommand { get; }
+ public DelegateCommand BrowseLogPathCommand { get; }
public string[] RaySourceTypes { get; } =
{
@@ -54,11 +58,12 @@ namespace XplorePlane.ViewModels.Setting
private set => SetProperty(ref _isFactorySettingsVisible, value);
}
- public SettingsViewModel(ILoggerService logger, IPermissionService permissionService, IEventAggregator eventAggregator)
+ public SettingsViewModel(ILoggerService logger, IPermissionService permissionService, IEventAggregator eventAggregator, IXpDataPathService dataPathService)
{
_logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger));
_permissionService = permissionService ?? throw new ArgumentNullException(nameof(permissionService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
+ _dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
_logger.Info("SettingsViewModel 构造函数被调用 | SettingsViewModel constructor called");
@@ -66,6 +71,8 @@ namespace XplorePlane.ViewModels.Setting
CancelCommand = new DelegateCommand(ExecuteCancel);
ResetToDefaultCommand = new DelegateCommand(ExecuteResetToDefault);
BrowseCameraImageCommand = new DelegateCommand(ExecuteBrowseCameraImage);
+ BrowseDataRootPathCommand = new DelegateCommand(ExecuteBrowseDataRootPath);
+ BrowseLogPathCommand = new DelegateCommand(ExecuteBrowseLogPath);
_logger.Debug("Commands initialized: SaveCommand={SaveCommand}, CancelCommand={CancelCommand}, ResetToDefaultCommand={ResetToDefaultCommand}",
SaveCommand != null, CancelCommand != null, ResetToDefaultCommand != null);
@@ -89,6 +96,18 @@ namespace XplorePlane.ViewModels.Setting
}
#endregion
+ #region 数据存储根目录
+ private string _dataRootPath;
+ ///
+ /// 数据存储根目录,所有子目录(Logs、DataBase、DetectorImages 等)基于此路径。
+ ///
+ public string DataRootPath
+ {
+ get => _dataRootPath;
+ set => SetProperty(ref _dataRootPath, value);
+ }
+ #endregion
+
#region Serilog日志配置
private string _serilogLogPath;
public string SerilogLogPath
@@ -355,6 +374,9 @@ namespace XplorePlane.ViewModels.Setting
Language = GetAppSetting("Language", "ZhCN");
_logger.Debug("Loaded Language: {Language}", Language);
+ // 数据存储根目录
+ DataRootPath = _dataPathService.RootPath;
+
// Serilog日志配置
SerilogLogPath = GetAppSetting("Serilog:LogPath", "D:\\XPData\\Logs");
SerilogMinimumLevel = GetAppSetting("Serilog:MinimumLevel", "Debug");
@@ -469,6 +491,9 @@ namespace XplorePlane.ViewModels.Setting
config.Save(ConfigurationSaveMode.Modified);
ConfigurationManager.RefreshSection("appSettings");
+ // 数据存储根目录(独立保存,避免与上面的 config 对象冲突)
+ _dataPathService.SaveRootPath(DataRootPath);
+
// 导航相机配置保存到 config.json
SaveCameraConfigToJson();
@@ -531,6 +556,48 @@ namespace XplorePlane.ViewModels.Setting
}
}
+ private void ExecuteBrowseDataRootPath()
+ {
+ try
+ {
+ var dialog = new Microsoft.Win32.OpenFolderDialog
+ {
+ Title = "选择数据存储根目录",
+ InitialDirectory = System.IO.Directory.Exists(DataRootPath) ? DataRootPath : _dataPathService.DefaultRootPath
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ DataRootPath = dialog.FolderName;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "浏览数据根目录失败");
+ }
+ }
+
+ private void ExecuteBrowseLogPath()
+ {
+ try
+ {
+ var dialog = new Microsoft.Win32.OpenFolderDialog
+ {
+ Title = "选择日志存储目录",
+ InitialDirectory = System.IO.Directory.Exists(SerilogLogPath) ? SerilogLogPath : _dataPathService.RootPath
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ SerilogLogPath = dialog.FolderName;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "浏览日志目录失败");
+ }
+ }
+
private void OnRoleChanged(RoleChangedPayload payload)
{
RefreshSettingsVisibility();
diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml
index adb8b79f..d1b94e62 100644
--- a/XplorePlane/Views/Cnc/CncPageView.xaml
+++ b/XplorePlane/Views/Cnc/CncPageView.xaml
@@ -344,6 +344,42 @@
+
+
+
+
diff --git a/XplorePlane/Views/Setting/SettingsWindow.xaml b/XplorePlane/Views/Setting/SettingsWindow.xaml
index 55bca8d0..5c446fe0 100644
--- a/XplorePlane/Views/Setting/SettingsWindow.xaml
+++ b/XplorePlane/Views/Setting/SettingsWindow.xaml
@@ -126,6 +126,40 @@
Content="启动时默认显示实时图像" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -222,6 +256,7 @@
+
+