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="启动时默认显示实时图像" /> + + + + + + + + + + + + + + + +