From 67898edc3fc8f4025b4d596cedb388dd2c293e95 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Wed, 18 Mar 2026 20:14:08 +0800 Subject: [PATCH] =?UTF-8?q?#0039=20=E5=85=A8=E5=B1=80=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Generators/RecipeGenerators.cs | 105 +++++++ .../Generators/StateGenerators.cs | 146 +++++++++ XplorePlane.Tests/Models/StateModelsTests.cs | 153 ++++++++++ .../Services/AppStateServiceTests.cs | 121 ++++++++ .../Services/RecipeServiceTests.cs | 128 ++++++++ XplorePlane.Tests/XplorePlane.Tests.csproj | 29 +- XplorePlane/App.xaml.cs | 24 ++ XplorePlane/Assets/Icons/crosshair.png | Bin 0 -> 279 bytes XplorePlane/Assets/Icons/dynamic-range.png | Bin 0 -> 4106 bytes XplorePlane/Assets/Icons/film-darken.png | Bin 0 -> 2125 bytes XplorePlane/Assets/Icons/sharpen.png | Bin 0 -> 7199 bytes XplorePlane/Models/RecipeModels.cs | 44 +++ XplorePlane/Models/StateModels.cs | 120 ++++++++ .../Services/AppState/AppStateService.cs | 232 ++++++++++++++ .../Services/AppState/IAppStateService.cs | 54 ++++ .../AppState/StateChangedEventArgs.cs | 32 ++ XplorePlane/Services/Recipe/IRecipeService.cs | 31 ++ XplorePlane/Services/Recipe/RecipeService.cs | 282 ++++++++++++++++++ XplorePlane/readme.txt | 5 +- 19 files changed, 1490 insertions(+), 16 deletions(-) create mode 100644 XplorePlane.Tests/Generators/RecipeGenerators.cs create mode 100644 XplorePlane.Tests/Generators/StateGenerators.cs create mode 100644 XplorePlane.Tests/Models/StateModelsTests.cs create mode 100644 XplorePlane.Tests/Services/AppStateServiceTests.cs create mode 100644 XplorePlane.Tests/Services/RecipeServiceTests.cs create mode 100644 XplorePlane/Assets/Icons/crosshair.png create mode 100644 XplorePlane/Assets/Icons/dynamic-range.png create mode 100644 XplorePlane/Assets/Icons/film-darken.png create mode 100644 XplorePlane/Assets/Icons/sharpen.png create mode 100644 XplorePlane/Models/RecipeModels.cs create mode 100644 XplorePlane/Models/StateModels.cs create mode 100644 XplorePlane/Services/AppState/AppStateService.cs create mode 100644 XplorePlane/Services/AppState/IAppStateService.cs create mode 100644 XplorePlane/Services/AppState/StateChangedEventArgs.cs create mode 100644 XplorePlane/Services/Recipe/IRecipeService.cs create mode 100644 XplorePlane/Services/Recipe/RecipeService.cs diff --git a/XplorePlane.Tests/Generators/RecipeGenerators.cs b/XplorePlane.Tests/Generators/RecipeGenerators.cs new file mode 100644 index 0000000..976ef23 --- /dev/null +++ b/XplorePlane.Tests/Generators/RecipeGenerators.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FsCheck; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Generators +{ + /// + /// FsCheck 自定义生成器:为配方相关模型定义 Arbitrary 生成器。 + /// 使用 Arb.Register<RecipeGenerators>() 注册。 + /// + public class RecipeGenerators + { + // ── PipelineModel: class with settable properties ── + + public static Arbitrary PipelineModelArb() + { + var gen = from id in Arb.Default.Guid().Generator + from name in Arb.Default.NonEmptyString().Generator + from deviceId in Arb.Default.NonEmptyString().Generator + from ticks in Gen.Choose(0, int.MaxValue) + let createdAt = DateTime.MinValue.AddTicks((long)ticks * 10000) + let updatedAt = createdAt + select new PipelineModel + { + Id = id, + Name = name.Get, + DeviceId = deviceId.Get, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + Nodes = new List() + }; + return Arb.From(gen); + } + + // ── RecipeStep: reuses StateGenerators for hardware states ── + + public static Arbitrary RecipeStepArb() + { + var gen = from stepIndex in Gen.Choose(0, 100) + from motion in StateGenerators.MotionStateArb().Generator + from ray in StateGenerators.RaySourceStateArb().Generator + from detector in StateGenerators.DetectorStateArb().Generator + from pipeline in PipelineModelArb().Generator + select new RecipeStep(stepIndex, motion, ray, detector, pipeline); + return Arb.From(gen); + } + + // ── InspectionRecipe: 0-10 steps with sequential StepIndex ── + + public static Arbitrary InspectionRecipeArb() + { + var gen = from id in Arb.Default.Guid().Generator + from name in Arb.Default.NonEmptyString().Generator + from ticks in Gen.Choose(0, int.MaxValue) + let createdAt = DateTime.MinValue.AddTicks((long)ticks * 10000) + let updatedAt = createdAt + from stepCount in Gen.Choose(0, 10) + from steps in GenSequentialSteps(stepCount) + select new InspectionRecipe( + id, + name.Get, + createdAt, + updatedAt, + steps.AsReadOnly()); + return Arb.From(gen); + } + + // ── RecipeExecutionState ── + + public static Arbitrary RecipeExecutionStateArb() + { + var gen = from totalSteps in Gen.Choose(0, 50) + from currentStep in Gen.Choose(0, Math.Max(totalSteps, 1) - 1) + from status in Gen.Elements( + RecipeExecutionStatus.Idle, + RecipeExecutionStatus.Running, + RecipeExecutionStatus.Paused, + RecipeExecutionStatus.Completed, + RecipeExecutionStatus.Error) + from recipeName in Arb.Default.NonEmptyString().Generator + select new RecipeExecutionState(currentStep, totalSteps, status, recipeName.Get); + return Arb.From(gen); + } + + // ── Helper: generate a list of steps with sequential StepIndex 0..n-1 ── + + private static Gen> GenSequentialSteps(int count) + { + if (count == 0) + return Gen.Constant(new List()); + + return Gen.Sequence( + Enumerable.Range(0, count).Select(i => + from motion in StateGenerators.MotionStateArb().Generator + from ray in StateGenerators.RaySourceStateArb().Generator + from detector in StateGenerators.DetectorStateArb().Generator + from pipeline in PipelineModelArb().Generator + select new RecipeStep(i, motion, ray, detector, pipeline) + ) + ).Select(steps => steps.ToList()); + } + } +} diff --git a/XplorePlane.Tests/Generators/StateGenerators.cs b/XplorePlane.Tests/Generators/StateGenerators.cs new file mode 100644 index 0000000..863c9fe --- /dev/null +++ b/XplorePlane.Tests/Generators/StateGenerators.cs @@ -0,0 +1,146 @@ +using System; +using FsCheck; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Generators +{ + /// + /// FsCheck 自定义生成器:为所有状态模型定义 Arbitrary 生成器。 + /// 使用 Arb.Register<StateGenerators>() 注册。 + /// + public class StateGenerators + { + // ── 位置范围: -10000 ~ 10000, 速度范围: 0 ~ 1000 ── + + private static Gen PositionGen => + Gen.Choose(-10000000, 10000000).Select(i => i / 1000.0); + + private static Gen SpeedGen => + Gen.Choose(0, 1000000).Select(i => i / 1000.0); + + private static Gen VoltageGen => + Gen.Choose(0, 450000).Select(i => i / 1000.0); + + private static Gen PowerGen => + Gen.Choose(0, 3000000).Select(i => i / 1000.0); + + private static Gen FrameRateGen => + Gen.Choose(0, 120000).Select(i => i / 1000.0); + + private static Gen MatrixGen => + Gen.Choose(-10000000, 10000000).Select(i => i / 1000.0); + + // ── MotionState: 6 positions + 6 speeds ── + + public static Arbitrary MotionStateArb() + { + var gen = from xm in PositionGen + from ym in PositionGen + from zt in PositionGen + from zd in PositionGen + from tiltD in PositionGen + from dist in PositionGen + from xmSpd in SpeedGen + from ymSpd in SpeedGen + from ztSpd in SpeedGen + from zdSpd in SpeedGen + from tiltDSpd in SpeedGen + from distSpd in SpeedGen + select new MotionState(xm, ym, zt, zd, tiltD, dist, + xmSpd, ymSpd, ztSpd, zdSpd, tiltDSpd, distSpd); + return Arb.From(gen); + } + + // ── RaySourceState: bool + 2 doubles ── + + public static Arbitrary RaySourceStateArb() + { + var gen = from isOn in Arb.Default.Bool().Generator + from voltage in VoltageGen + from power in PowerGen + select new RaySourceState(isOn, voltage, power); + return Arb.From(gen); + } + + // ── DetectorState: 2 bools + double + string ── + + public static Arbitrary DetectorStateArb() + { + var gen = from isConnected in Arb.Default.Bool().Generator + from isAcquiring in Arb.Default.Bool().Generator + from frameRate in FrameRateGen + from res in Gen.Elements("1024x1024", "2048x2048", "2880x2880", "3072x3072", "4260x4260") + select new DetectorState(isConnected, isAcquiring, frameRate, res); + return Arb.From(gen); + } + + // ── SystemState: OperationMode enum + bool + string ── + + public static Arbitrary SystemStateArb() + { + var gen = from mode in Gen.Elements( + OperationMode.Idle, + OperationMode.Scanning, + OperationMode.CTAcquire, + OperationMode.RecipeRun) + from hasError in Arb.Default.Bool().Generator + from msg in Arb.Default.NonEmptyString().Generator + let errorMessage = hasError ? msg.Get : string.Empty + select new SystemState(mode, hasError, errorMessage); + return Arb.From(gen); + } + + // ── CameraState: 2 bools + null CurrentFrame + 2 ints + double ── + + public static Arbitrary CameraStateArb() + { + var gen = from isConnected in Arb.Default.Bool().Generator + from isStreaming in Arb.Default.Bool().Generator + from width in Gen.Choose(0, 8192) + from height in Gen.Choose(0, 8192) + from frameRate in FrameRateGen + select new CameraState(isConnected, isStreaming, null, width, height, frameRate); + return Arb.From(gen); + } + + // ── CalibrationMatrix: 9 doubles ── + + public static Arbitrary CalibrationMatrixArb() + { + var gen = from m11 in MatrixGen + from m12 in MatrixGen + from m13 in MatrixGen + from m21 in MatrixGen + from m22 in MatrixGen + from m23 in MatrixGen + from m31 in MatrixGen + from m32 in MatrixGen + from m33 in MatrixGen + select new CalibrationMatrix(m11, m12, m13, m21, m22, m23, m31, m32, m33); + return Arb.From(gen); + } + + // ── PhysicalPosition: 3 doubles ── + + public static Arbitrary PhysicalPositionArb() + { + var gen = from x in PositionGen + from y in PositionGen + from z in PositionGen + select new PhysicalPosition(x, y, z); + return Arb.From(gen); + } + + // ── LinkedViewState: PhysicalPosition + bool + DateTime ── + + public static Arbitrary LinkedViewStateArb() + { + var gen = from pos in PhysicalPositionArb().Generator + from isExecuting in Arb.Default.Bool().Generator + from ticks in Gen.Choose(0, int.MaxValue) + let dt = DateTime.MinValue.AddTicks((long)ticks * 10000) + select new LinkedViewState(pos, isExecuting, dt); + return Arb.From(gen); + } + } +} diff --git a/XplorePlane.Tests/Models/StateModelsTests.cs b/XplorePlane.Tests/Models/StateModelsTests.cs new file mode 100644 index 0000000..6861525 --- /dev/null +++ b/XplorePlane.Tests/Models/StateModelsTests.cs @@ -0,0 +1,153 @@ +using System; +using Xunit; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Models +{ + public class StateModelsTests + { + // ── Default Value Tests ─────────────────────────────────────── + + [Fact] + public void MotionState_Default_AllZeros() + { + var state = MotionState.Default; + + Assert.Equal(0, state.XM); + Assert.Equal(0, state.YM); + Assert.Equal(0, state.ZT); + Assert.Equal(0, state.ZD); + Assert.Equal(0, state.TiltD); + Assert.Equal(0, state.Dist); + Assert.Equal(0, state.XMSpeed); + Assert.Equal(0, state.YMSpeed); + Assert.Equal(0, state.ZTSpeed); + Assert.Equal(0, state.ZDSpeed); + Assert.Equal(0, state.TiltDSpeed); + Assert.Equal(0, state.DistSpeed); + } + + [Fact] + public void RaySourceState_Default_IsOffAndZeros() + { + var state = RaySourceState.Default; + + Assert.False(state.IsOn); + Assert.Equal(0, state.Voltage); + Assert.Equal(0, state.Power); + } + + [Fact] + public void DetectorState_Default_DisconnectedAndZeros() + { + var state = DetectorState.Default; + + Assert.False(state.IsConnected); + Assert.False(state.IsAcquiring); + Assert.Equal(0, state.FrameRate); + Assert.Equal(string.Empty, state.Resolution); + } + + [Fact] + public void SystemState_Default_IdleNoError() + { + var state = SystemState.Default; + + Assert.Equal(OperationMode.Idle, state.OperationMode); + Assert.False(state.HasError); + Assert.Equal(string.Empty, state.ErrorMessage); + } + + [Fact] + public void CameraState_Default_DisconnectedAndZeros() + { + var state = CameraState.Default; + + Assert.False(state.IsConnected); + Assert.False(state.IsStreaming); + Assert.Null(state.CurrentFrame); + Assert.Equal(0, state.Width); + Assert.Equal(0, state.Height); + Assert.Equal(0, state.FrameRate); + } + + [Fact] + public void LinkedViewState_Default_ZeroPositionNotExecuting() + { + var state = LinkedViewState.Default; + + Assert.Equal(0, state.TargetPosition.X); + Assert.Equal(0, state.TargetPosition.Y); + Assert.Equal(0, state.TargetPosition.Z); + Assert.False(state.IsExecuting); + Assert.Equal(DateTime.MinValue, state.LastRequestTime); + } + + [Fact] + public void RecipeExecutionState_Default_IdleAndZeros() + { + var state = RecipeExecutionState.Default; + + Assert.Equal(0, state.CurrentStepIndex); + Assert.Equal(0, state.TotalSteps); + Assert.Equal(RecipeExecutionStatus.Idle, state.Status); + Assert.Equal(string.Empty, state.CurrentRecipeName); + } + + // ── Immutability Test ───────────────────────────────────────── + + [Fact] + public void MotionState_WithExpression_ProducesNewInstance() + { + var original = MotionState.Default; + + var modified = original with { XM = 100 }; + + // New instance is different from original + Assert.NotSame(original, modified); + Assert.Equal(100, modified.XM); + + // Original is unchanged + Assert.Equal(0, original.XM); + } + + // ── CalibrationMatrix Transform Tests ───────────────────────── + + [Fact] + public void CalibrationMatrix_Transform_CorrectCalculation() + { + // Known matrix: scaling X by 2, Y by 3, with offsets + var matrix = new CalibrationMatrix( + M11: 2, M12: 0, M13: 10, + M21: 0, M22: 3, M23: 20, + M31: 0, M32: 0, M33: 0 + ); + + var (x, y, z) = matrix.Transform(pixelX: 5, pixelY: 8); + + // x = 2*5 + 0*8 + 10 = 20 + Assert.Equal(20, x); + // y = 0*5 + 3*8 + 20 = 44 + Assert.Equal(44, y); + // z = 0*5 + 0*8 + 0 = 0 + Assert.Equal(0, z); + } + + [Fact] + public void CalibrationMatrix_Transform_IdentityMatrix() + { + // Identity-like matrix: should return (pixelX, pixelY, 0) approximately + var identity = new CalibrationMatrix( + M11: 1, M12: 0, M13: 0, + M21: 0, M22: 1, M23: 0, + M31: 0, M32: 0, M33: 0 + ); + + var (x, y, z) = identity.Transform(pixelX: 42.5, pixelY: 99.1); + + Assert.Equal(42.5, x, precision: 10); + Assert.Equal(99.1, y, precision: 10); + Assert.Equal(0, z, precision: 10); + } + } +} diff --git a/XplorePlane.Tests/Services/AppStateServiceTests.cs b/XplorePlane.Tests/Services/AppStateServiceTests.cs new file mode 100644 index 0000000..5289d97 --- /dev/null +++ b/XplorePlane.Tests/Services/AppStateServiceTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Windows; +using Moq; +using Serilog; +using Xunit; +using XP.Hardware.RaySource.Services; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.Tests.Services +{ + /// + /// AppStateService 单元测试。 + /// 验证默认状态值、Dispose 后行为、null 参数校验、CalibrationMatrix 缺失时的错误处理。 + /// + public class AppStateServiceTests : IDisposable + { + private readonly AppStateService _service; + private readonly Mock _mockRaySource; + private readonly Mock _mockLogger; + + public AppStateServiceTests() + { + // Ensure WPF Application exists for Dispatcher + if (Application.Current == null) + { + new Application(); + } + + _mockRaySource = new Mock(); + _mockLogger = new Mock(); + _service = new AppStateService(_mockRaySource.Object, _mockLogger.Object); + } + + public void Dispose() + { + _service.Dispose(); + } + + // ── 默认状态值验证 ── + + [Fact] + public void DefaultState_MotionState_IsDefault() + { + Assert.Same(MotionState.Default, _service.MotionState); + } + + [Fact] + public void DefaultState_RaySourceState_IsDefault() + { + Assert.Same(RaySourceState.Default, _service.RaySourceState); + } + + [Fact] + public void DefaultState_SystemState_IsDefault() + { + Assert.Same(SystemState.Default, _service.SystemState); + } + + [Fact] + public void DefaultState_CalibrationMatrix_IsNull() + { + Assert.Null(_service.CalibrationMatrix); + } + + // ── null 参数抛出 ArgumentNullException ── + + [Fact] + public void UpdateMotionState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateMotionState(null!)); + } + + [Fact] + public void UpdateRaySourceState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateRaySourceState(null!)); + } + + [Fact] + public void UpdateDetectorState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateDetectorState(null!)); + } + + [Fact] + public void UpdateSystemState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateSystemState(null!)); + } + + // ── Dispose 后 Update 被忽略 ── + + [Fact] + public void Dispose_ThenUpdate_IsIgnored() + { + var originalState = _service.MotionState; + _service.Dispose(); + + // Should not throw, and state should remain unchanged + var newState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + _service.UpdateMotionState(newState); + + Assert.Same(originalState, _service.MotionState); + } + + // ── CalibrationMatrix 为 null 时 RequestLinkedView 设置错误状态 ── + + [Fact] + public void RequestLinkedView_NoCalibrationMatrix_SetsErrorState() + { + // CalibrationMatrix is null by default + Assert.Null(_service.CalibrationMatrix); + + _service.RequestLinkedView(100.0, 200.0); + + Assert.True(_service.SystemState.HasError); + Assert.NotEmpty(_service.SystemState.ErrorMessage); + } + } +} diff --git a/XplorePlane.Tests/Services/RecipeServiceTests.cs b/XplorePlane.Tests/Services/RecipeServiceTests.cs new file mode 100644 index 0000000..132af1e --- /dev/null +++ b/XplorePlane.Tests/Services/RecipeServiceTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Moq; +using Serilog; +using Xunit; +using XplorePlane.Models; +using XplorePlane.Services; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Recipe; + +namespace XplorePlane.Tests.Services +{ + /// + /// RecipeService 单元测试。 + /// 验证配方创建、步骤记录、文件加载错误处理和名称校验。 + /// + public class RecipeServiceTests + { + private readonly Mock _mockAppState; + private readonly Mock _mockPipeline; + private readonly Mock _mockLogger; + private readonly RecipeService _service; + + public RecipeServiceTests() + { + _mockAppState = new Mock(); + _mockPipeline = new Mock(); + _mockLogger = new Mock(); + + // Setup default state returns + _mockAppState.Setup(s => s.MotionState).Returns(MotionState.Default); + _mockAppState.Setup(s => s.RaySourceState).Returns(RaySourceState.Default); + _mockAppState.Setup(s => s.DetectorState).Returns(DetectorState.Default); + + _service = new RecipeService( + _mockAppState.Object, + _mockPipeline.Object, + _mockLogger.Object); + } + + // ── CreateRecipe 验证 ── + + [Fact] + public void CreateRecipe_ValidName_ReturnsEmptyRecipe() + { + var recipe = _service.CreateRecipe("TestRecipe"); + + Assert.Equal("TestRecipe", recipe.Name); + Assert.Empty(recipe.Steps); + Assert.NotEqual(Guid.Empty, recipe.Id); + Assert.True(recipe.CreatedAt <= DateTime.UtcNow); + Assert.True(recipe.UpdatedAt <= DateTime.UtcNow); + } + + [Fact] + public void CreateRecipe_EmptyName_ThrowsArgumentException() + { + Assert.Throws(() => _service.CreateRecipe(string.Empty)); + } + + [Fact] + public void CreateRecipe_NullName_ThrowsArgumentException() + { + Assert.Throws(() => _service.CreateRecipe(null!)); + } + + [Fact] + public void CreateRecipe_WhitespaceName_ThrowsArgumentException() + { + Assert.Throws(() => _service.CreateRecipe(" ")); + } + + // ── RecordCurrentStep 验证 ── + + [Fact] + public void RecordCurrentStep_CapturesCurrentState() + { + var motionState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + var raySourceState = new RaySourceState(true, 120.0, 80.0); + var detectorState = new DetectorState(true, true, 30.0, "2048x2048"); + + _mockAppState.Setup(s => s.MotionState).Returns(motionState); + _mockAppState.Setup(s => s.RaySourceState).Returns(raySourceState); + _mockAppState.Setup(s => s.DetectorState).Returns(detectorState); + + var recipe = _service.CreateRecipe("TestRecipe"); + var pipeline = new PipelineModel { Name = "TestPipeline" }; + + var step = _service.RecordCurrentStep(recipe, pipeline); + + Assert.Equal(0, step.StepIndex); + Assert.Same(motionState, step.MotionState); + Assert.Same(raySourceState, step.RaySourceState); + Assert.Same(detectorState, step.DetectorState); + Assert.Same(pipeline, step.Pipeline); + } + + // ── LoadAsync 错误处理 ── + + [Fact] + public async Task LoadAsync_FileNotExists_ThrowsFileNotFoundException() + { + var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".json"); + + await Assert.ThrowsAsync( + () => _service.LoadAsync(nonExistentPath)); + } + + [Fact] + public async Task LoadAsync_InvalidJson_ThrowsInvalidDataException() + { + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(tempFile, "{ this is not valid json !!! }"); + + await Assert.ThrowsAsync( + () => _service.LoadAsync(tempFile)); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + } +} diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj index b2c1ace..229c12e 100644 --- a/XplorePlane.Tests/XplorePlane.Tests.csproj +++ b/XplorePlane.Tests/XplorePlane.Tests.csproj @@ -1,33 +1,34 @@ + net8.0-windows true - XplorePlane.Tests false - enable + XplorePlane.Tests - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - + + + + + + + + + + - - - ..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll + + ..\XplorePlane\Libs\Hardware\XP.Hardware.RaySource.dll True + diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 14f3155..ba2bc35 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -6,6 +6,8 @@ using System.Windows; using XplorePlane.Views; using XplorePlane.ViewModels; using XplorePlane.Services; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Recipe; using Prism.Ioc; using Prism.DryIoc; using Prism.Modularity; @@ -88,6 +90,22 @@ namespace XplorePlane Log.Information("XplorePlane 应用程序退出"); Log.Information("========================================"); + // 释放全局状态服务资源 + try + { + var bootstrapper = AppBootstrapper.Instance; + if (bootstrapper != null) + { + var appStateService = bootstrapper.Container.Resolve(); + appStateService?.Dispose(); + Log.Information("全局状态服务资源已释放"); + } + } + catch (Exception ex) + { + Log.Error(ex, "全局状态服务资源释放失败"); + } + // 释放射线源资源 try { @@ -179,6 +197,12 @@ namespace XplorePlane containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + // 注册全局状态服务(单例) + containerRegistry.RegisterSingleton(); + + // 注册检测配方服务(单例) + containerRegistry.RegisterSingleton(); + // 注册流水线 ViewModel(每次解析创建新实例) containerRegistry.Register(); containerRegistry.Register(); diff --git a/XplorePlane/Assets/Icons/crosshair.png b/XplorePlane/Assets/Icons/crosshair.png new file mode 100644 index 0000000000000000000000000000000000000000..313f75fd6d9c54a671dabebd25ff5f6b7df00338 GIT binary patch literal 279 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 literal 0 HcmV?d00001 diff --git a/XplorePlane/Assets/Icons/film-darken.png b/XplorePlane/Assets/Icons/film-darken.png new file mode 100644 index 0000000000000000000000000000000000000000..605cd9cfbacee6f0d560e3507d4a0c8e56797698 GIT binary patch literal 2125 zcmZvedsx$08i(5wFfCHkf)$}^+JZ$zg`!Br(WF!?AOb1_URMDH)D_5xg4Z^JTrDZ1 zB3>9+abz4Xh>j?tQ-!5*U_b_Ey`dlt7Zp@+K?Kwd3xDnGA4%Ttd!GExd-5bXCoOQ< z5-Xk^kHunH`KVRPnJYDXfELX8V(6tj77OI~sIZj@?9Nkfa-VsRWzBmf`Rs5M`KjiW z++Rfo^u2P>&k$y`cX)PSv$W6IQ$MvgjhPLi&5mp!G3}5&WNU^U=YZQFl(h0_Bv^!t z8T1$~4kQnsZzND52h1)E+SZW5BKl*`ZTU-ynEP;Y{@y0nz@PR|T#*nZlVg2O|G*;X zcM^H1nuUq(ImYkjfIVkZhLQ}C96Mqt&Zl4Qy20h1EBQnmxt?jnKQZhIGi*o-hriaB zWwv!MvchyB?LHvDq~5i!?AyzQQzspm?&zR_xhJcRb%9K|^{?dyM=EB5d!=oL?X{Z0 zI+ui1+^|?1-#OCvA6qXp&AKu)4m&3MQKMkHArVcW)F#y#SlS9X4bxblW|STQVU2Nm zFP#onYEzdQ{P|j9n0yVvqog*h8zpZzNz5_Fk~lAw&5P?@aOw-Ba)N@uq-?KzI5iNK zLZCba#h?N-!5W-4NtMuhWkJ<+V%c~`U!x}Td!kiI}YuqEGSN?IH)I6;R z_>GdBRx(CW0w>22k0{7#)bf;L;PR2Fd3fbt5OdC^!?@^P9vY1Raz>~H)b`+@&G3M? zfYsERcw*xa{9qX$naA7|mWBF_xQAt+YeuMLy!KzCL2jI`5%Y5C z_H}fBhZV6`(`=Hn>Hf5^vA`$b;AJzU>a!*GHgU(7WN|=Q795a2r#wkK`-BdjZHV}F zsOpP8+)PAlGepN)B0EoH!k!vlleaFdO-ccUgdZ7R@)M}wo`0aA6VBvj5*#vi+6%Ixz#nX3eMWs~50<}40MzH-a36zTXuXv?33Knh(WK(Hi##k_ z_6|I5)yvQWkOOL+w~dYDq1`u<;K>h5w@QufQyOl8Vd;BHZShWN$vO>uy}b0i#3-6{ zZcn^<9A|g2GcE>72V8+w{t1f~;hk>y8ZErN<8rdzlrDK|PxSX%AVZG>LcGc7mFF$> zUgO)#B0yZzb$>3Z;jQIv{ z{7^Ks_4PKe$I==f3cFi6j``o20r*g}VQ>Nmytin~z$S`UBsCgJEYd=-iRrWnhy>#E4Xt_%OAU(B3 zZ)z*JB^nFdS@YC!Q#?kVsdkt>KXMaBhSxvX;kzanqC4jN)3R=L5JZQ1xIAATVjbb9 z+kU&XoCi$j5@MbimcEA*3}Cv!J3W4Bs5wS@Mcb-BsNq{M)%|$IB_|=}tcDRAPv!YQ zpoFn0s49B9S;0VjyKCe95+>uNcTwLV1p~iCy3(&oz&}ECWOzg5iiu3>!Vk7@3+v4> zU97BOtCyaE^;d0st_3kbn*NAcAuz}IXOP00h1w|Q0qm~}-Kd7MVQS;!1mE8UsZtQH z>CFhP0;C`zuJS+ExPTqQ%>T05;J42M}nqxwBpD+lHPpZ4D$obcb3 ztN)`S=iC>j6O$>imsDvmXA8wiQ;nY&>DHSl;uLUUbw^(@62k#op0i)4Ox3^z&nnu= zxeidJy`b0A8YOF=w|CwGpoj?q%lZFXcFa__*M8SGjRPu@(-yB|!XDLr_xAsluRbqc s5OmP+mDvk?{d(d1M-m1shIa#ItSYPa{OR#o4orvTvv`^6l5zv_AMwI!2LJ#7 literal 0 HcmV?d00001 diff --git a/XplorePlane/Assets/Icons/sharpen.png b/XplorePlane/Assets/Icons/sharpen.png new file mode 100644 index 0000000000000000000000000000000000000000..366616e8f59c82cb9efebee67d882d7f9ebc81ee GIT binary patch literal 7199 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检测配方中的单个步骤 + public record RecipeStep( + int StepIndex, + MotionState MotionState, + RaySourceState RaySourceState, + DetectorState DetectorState, + PipelineModel Pipeline + ); + + /// 检测配方(CNC 自动编程) + public record InspectionRecipe( + Guid Id, + string Name, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList Steps + ) + { + /// 追加步骤,返回新的 InspectionRecipe + public InspectionRecipe AddStep(RecipeStep step) => + this with + { + Steps = Steps.Append(step).ToList().AsReadOnly(), + UpdatedAt = DateTime.UtcNow + }; + } + + /// 配方执行状态(不可变) + public record RecipeExecutionState( + int CurrentStepIndex, + int TotalSteps, + RecipeExecutionStatus Status, + string CurrentRecipeName + ) + { + public static readonly RecipeExecutionState Default = new(0, 0, RecipeExecutionStatus.Idle, string.Empty); + } +} diff --git a/XplorePlane/Models/StateModels.cs b/XplorePlane/Models/StateModels.cs new file mode 100644 index 0000000..a494ca5 --- /dev/null +++ b/XplorePlane/Models/StateModels.cs @@ -0,0 +1,120 @@ +using System; + +namespace XplorePlane.Models +{ + // ── Enumerations ────────────────────────────────────────────────── + + /// 系统操作模式 + public enum OperationMode + { + Idle, // 空闲 + Scanning, // 扫描 + CTAcquire, // CT 采集 + RecipeRun // 配方执行中 + } + + /// 配方执行状态 + public enum RecipeExecutionStatus + { + Idle, // 空闲 + Running, // 运行中 + Paused, // 暂停 + Completed, // 已完成 + Error // 出错 + } + + // ── State Records ───────────────────────────────────────────────── + + /// 运动控制状态(不可变) + public record MotionState( + double XM, // X 轴位置 (μm) + double YM, // Y 轴位置 (μm) + double ZT, // Z 上轴位置 (μm) + double ZD, // Z 下轴位置 (μm) + double TiltD, // 倾斜角度 (m°) + double Dist, // 距离 (μm) + double XMSpeed, // X 轴速度 (μm/s) + double YMSpeed, // Y 轴速度 (μm/s) + double ZTSpeed, // Z 上轴速度 (μm/s) + double ZDSpeed, // Z 下轴速度 (μm/s) + double TiltDSpeed, // 倾斜速度 (m°/s) + double DistSpeed // 距离速度 (μm/s) + ) + { + public static readonly MotionState Default = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + /// 射线源状态(不可变) + public record RaySourceState( + bool IsOn, // 开关状态 + double Voltage, // 电压 (kV) + double Power // 功率 (W) + ) + { + public static readonly RaySourceState Default = new(false, 0, 0); + } + + /// 探测器状态(不可变) + public record DetectorState( + bool IsConnected, // 连接状态 + bool IsAcquiring, // 是否正在采集 + double FrameRate, // 当前帧率 (fps) + string Resolution // 分辨率描述,如 "2048x2048" + ) + { + public static readonly DetectorState Default = new(false, false, 0, string.Empty); + } + + /// 系统级状态(不可变) + public record SystemState( + OperationMode OperationMode, // 当前操作模式 + bool HasError, // 是否存在系统错误 + string ErrorMessage // 错误描述 + ) + { + public static readonly SystemState Default = new(OperationMode.Idle, false, string.Empty); + } + + /// 摄像头视频流状态(不可变) + public record CameraState( + bool IsConnected, // 连接状态 + bool IsStreaming, // 是否正在推流 + object CurrentFrame, // 当前帧数据引用(BitmapSource 或 byte[],Frozen) + int Width, // 分辨率宽 + int Height, // 分辨率高 + double FrameRate // 帧率 (fps) + ) + { + public static readonly CameraState Default = new(false, false, null, 0, 0, 0); + } + + /// 物理坐标 + public record PhysicalPosition(double X, double Y, double Z); + + /// 图像标定矩阵,像素坐标 → 物理坐标映射 + public record CalibrationMatrix( + double M11, double M12, double M13, // 3x3 仿射变换矩阵 + double M21, double M22, double M23, + double M31, double M32, double M33 + ) + { + /// 将像素坐标转换为物理坐标 + public (double X, double Y, double Z) Transform(double pixelX, double pixelY) + { + double x = M11 * pixelX + M12 * pixelY + M13; + double y = M21 * pixelX + M22 * pixelY + M23; + double z = M31 * pixelX + M32 * pixelY + M33; + return (x, y, z); + } + } + + /// 画面联动状态(不可变) + public record LinkedViewState( + PhysicalPosition TargetPosition, // 目标物理坐标 + bool IsExecuting, // 联动是否正在执行 + DateTime LastRequestTime // 最近一次联动请求时间 + ) + { + public static readonly LinkedViewState Default = new(new PhysicalPosition(0, 0, 0), false, DateTime.MinValue); + } +} diff --git a/XplorePlane/Services/AppState/AppStateService.cs b/XplorePlane/Services/AppState/AppStateService.cs new file mode 100644 index 0000000..74d127d --- /dev/null +++ b/XplorePlane/Services/AppState/AppStateService.cs @@ -0,0 +1,232 @@ +using System; +using System.Threading; +using System.Windows; +using System.Windows.Threading; +using Prism.Mvvm; +using Serilog; +using XP.Hardware.RaySource.Services; +using XplorePlane.Models; + +namespace XplorePlane.Services.AppState +{ + /// + /// 全局应用状态管理服务实现。 + /// 继承 BindableBase 以支持 WPF 数据绑定,使用 Interlocked.Exchange 保证线程安全写入, + /// 通过 Dispatcher.BeginInvoke 将事件调度到 UI 线程。 + /// + public class AppStateService : BindableBase, IAppStateService + { + private readonly Dispatcher _dispatcher; + private readonly IRaySourceService _raySourceService; + private readonly ILogger _logger; + private bool _disposed; + + // ── 状态字段(通过 Interlocked.Exchange 原子替换)── + private MotionState _motionState = MotionState.Default; + private RaySourceState _raySourceState = RaySourceState.Default; + private DetectorState _detectorState = DetectorState.Default; + private SystemState _systemState = SystemState.Default; + private CameraState _cameraState = CameraState.Default; + private CalibrationMatrix _calibrationMatrix; + private LinkedViewState _linkedViewState = LinkedViewState.Default; + private RecipeExecutionState _recipeExecutionState = RecipeExecutionState.Default; + + // ── 类型化状态变更事件 ── + public event EventHandler> MotionStateChanged; + public event EventHandler> RaySourceStateChanged; + public event EventHandler> DetectorStateChanged; + public event EventHandler> SystemStateChanged; + public event EventHandler> CameraStateChanged; + public event EventHandler> LinkedViewStateChanged; + public event EventHandler> RecipeExecutionStateChanged; + public event EventHandler LinkedViewRequested; + + // ── 状态属性(只读)── + public MotionState MotionState => _motionState; + public RaySourceState RaySourceState => _raySourceState; + public DetectorState DetectorState => _detectorState; + public SystemState SystemState => _systemState; + public CameraState CameraState => _cameraState; + public CalibrationMatrix CalibrationMatrix => _calibrationMatrix; + public LinkedViewState LinkedViewState => _linkedViewState; + public RecipeExecutionState RecipeExecutionState => _recipeExecutionState; + + public AppStateService( + IRaySourceService raySourceService, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(raySourceService); + ArgumentNullException.ThrowIfNull(logger); + + _raySourceService = raySourceService; + _logger = logger; + _dispatcher = Application.Current.Dispatcher; + + SubscribeToExistingServices(); + _logger.Information("AppStateService 已初始化"); + } + + // ── 状态更新方法 ── + + public void UpdateMotionState(MotionState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateMotionState 调用"); return; } + + var old = Interlocked.Exchange(ref _motionState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState)); + } + + public void UpdateRaySourceState(RaySourceState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateRaySourceState 调用"); return; } + + var old = Interlocked.Exchange(ref _raySourceState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, RaySourceStateChanged, nameof(RaySourceState)); + } + + public void UpdateDetectorState(DetectorState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateDetectorState 调用"); return; } + + var old = Interlocked.Exchange(ref _detectorState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, DetectorStateChanged, nameof(DetectorState)); + } + + public void UpdateSystemState(SystemState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateSystemState 调用"); return; } + + var old = Interlocked.Exchange(ref _systemState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, SystemStateChanged, nameof(SystemState)); + } + + public void UpdateCameraState(CameraState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateCameraState 调用"); return; } + + var old = Interlocked.Exchange(ref _cameraState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, CameraStateChanged, nameof(CameraState)); + } + + public void UpdateCalibrationMatrix(CalibrationMatrix newMatrix) + { + ArgumentNullException.ThrowIfNull(newMatrix); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateCalibrationMatrix 调用"); return; } + + var old = Interlocked.Exchange(ref _calibrationMatrix, newMatrix); + if (ReferenceEquals(old, newMatrix)) return; + + _dispatcher.BeginInvoke(() => + { + RaisePropertyChanged(nameof(CalibrationMatrix)); + }); + } + + public void UpdateLinkedViewState(LinkedViewState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateLinkedViewState 调用"); return; } + + var old = Interlocked.Exchange(ref _linkedViewState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, LinkedViewStateChanged, nameof(LinkedViewState)); + } + + public void UpdateRecipeExecutionState(RecipeExecutionState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateRecipeExecutionState 调用"); return; } + + var old = Interlocked.Exchange(ref _recipeExecutionState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, RecipeExecutionStateChanged, nameof(RecipeExecutionState)); + } + + // ── 画面联动 ── + + public void RequestLinkedView(double pixelX, double pixelY) + { + var matrix = _calibrationMatrix; + if (matrix is null) + { + _logger.Warning("CalibrationMatrix 未设置,无法执行画面联动 (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY); + UpdateSystemState(SystemState with + { + HasError = true, + ErrorMessage = "CalibrationMatrix 未设置,无法执行画面联动" + }); + return; + } + + var (x, y, z) = matrix.Transform(pixelX, pixelY); + var newLinkedState = new LinkedViewState( + new PhysicalPosition(x, y, z), + IsExecuting: true, + LastRequestTime: DateTime.UtcNow); + + UpdateLinkedViewState(newLinkedState); + + _dispatcher.BeginInvoke(() => + { + LinkedViewRequested?.Invoke(this, new LinkedViewRequestEventArgs(x, y, z)); + }); + } + + // ── 内部辅助方法 ── + + private void RaiseOnDispatcher(T oldVal, T newVal, + EventHandler> handler, string propertyName) + { + _dispatcher.BeginInvoke(() => + { + RaisePropertyChanged(propertyName); + try + { + handler?.Invoke(this, new StateChangedEventArgs(oldVal, newVal)); + } + catch (Exception ex) + { + _logger.Error(ex, "状态变更事件处理器抛出异常 (property={PropertyName})", propertyName); + } + }); + } + + private void SubscribeToExistingServices() + { + // IRaySourceService 是基于命令的服务(没有直接的状态变更事件), + // 状态同步由外部调用者通过 UpdateRaySourceState 完成。 + // TODO: 当 IRaySourceService 添加状态变更事件时,在此处订阅并同步到 RaySourceState + _logger.Information("AppStateService 已准备好接收外部服务状态更新"); + } + + // ── Dispose ── + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + // 清除所有事件订阅 + MotionStateChanged = null; + RaySourceStateChanged = null; + DetectorStateChanged = null; + SystemStateChanged = null; + CameraStateChanged = null; + LinkedViewStateChanged = null; + RecipeExecutionStateChanged = null; + LinkedViewRequested = null; + + _logger.Information("AppStateService 已释放"); + } + } +} diff --git a/XplorePlane/Services/AppState/IAppStateService.cs b/XplorePlane/Services/AppState/IAppStateService.cs new file mode 100644 index 0000000..aba3b11 --- /dev/null +++ b/XplorePlane/Services/AppState/IAppStateService.cs @@ -0,0 +1,54 @@ +using System; +using System.ComponentModel; +using XplorePlane.Models; + +namespace XplorePlane.Services.AppState +{ + /// + /// 全局应用状态管理服务接口。 + /// 以单例模式注册,聚合所有硬件和系统状态,支持 WPF 数据绑定和类型化事件订阅。 + /// + public interface IAppStateService : INotifyPropertyChanged, IDisposable + { + // ── 状态属性(只读,通过 Update 方法更新)── + + MotionState MotionState { get; } + RaySourceState RaySourceState { get; } + DetectorState DetectorState { get; } + SystemState SystemState { get; } + CameraState CameraState { get; } + CalibrationMatrix CalibrationMatrix { get; } + LinkedViewState LinkedViewState { get; } + RecipeExecutionState RecipeExecutionState { get; } + + // ── 状态更新方法(线程安全,可从任意线程调用)── + + void UpdateMotionState(MotionState newState); + void UpdateRaySourceState(RaySourceState newState); + void UpdateDetectorState(DetectorState newState); + void UpdateSystemState(SystemState newState); + void UpdateCameraState(CameraState newState); + void UpdateCalibrationMatrix(CalibrationMatrix newMatrix); + void UpdateLinkedViewState(LinkedViewState newState); + void UpdateRecipeExecutionState(RecipeExecutionState newState); + + // ── 画面联动 ── + + /// 将像素坐标通过 CalibrationMatrix 转换为物理坐标并发布 LinkedViewRequest + void RequestLinkedView(double pixelX, double pixelY); + + // ── 类型化状态变更事件 ── + + event EventHandler> MotionStateChanged; + event EventHandler> RaySourceStateChanged; + event EventHandler> DetectorStateChanged; + event EventHandler> SystemStateChanged; + event EventHandler> CameraStateChanged; + event EventHandler> LinkedViewStateChanged; + event EventHandler> RecipeExecutionStateChanged; + + // ── 画面联动请求事件 ── + + event EventHandler LinkedViewRequested; + } +} diff --git a/XplorePlane/Services/AppState/StateChangedEventArgs.cs b/XplorePlane/Services/AppState/StateChangedEventArgs.cs new file mode 100644 index 0000000..b8f3084 --- /dev/null +++ b/XplorePlane/Services/AppState/StateChangedEventArgs.cs @@ -0,0 +1,32 @@ +using System; + +namespace XplorePlane.Services.AppState +{ + /// 类型化状态变更事件参数,携带旧值和新值 + public sealed class StateChangedEventArgs : EventArgs + { + public T OldValue { get; } + public T NewValue { get; } + + public StateChangedEventArgs(T oldValue, T newValue) + { + OldValue = oldValue; + NewValue = newValue; + } + } + + /// 画面联动请求事件参数,携带目标物理坐标 + public sealed class LinkedViewRequestEventArgs : EventArgs + { + public double TargetX { get; } + public double TargetY { get; } + public double TargetZ { get; } + + public LinkedViewRequestEventArgs(double targetX, double targetY, double targetZ) + { + TargetX = targetX; + TargetY = targetY; + TargetZ = targetZ; + } + } +} diff --git a/XplorePlane/Services/Recipe/IRecipeService.cs b/XplorePlane/Services/Recipe/IRecipeService.cs new file mode 100644 index 0000000..829fc8d --- /dev/null +++ b/XplorePlane/Services/Recipe/IRecipeService.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Recipe +{ + /// 检测配方管理服务接口,负责配方的创建、序列化/反序列化和执行编排 + public interface IRecipeService + { + /// 创建空的检测配方 + InspectionRecipe CreateRecipe(string name); + + /// 从当前全局状态快照 + PipelineModel 记录一个步骤 + RecipeStep RecordCurrentStep(InspectionRecipe recipe, PipelineModel pipeline); + + /// 序列化配方到 JSON 文件 + Task SaveAsync(InspectionRecipe recipe, string filePath); + + /// 从 JSON 文件反序列化配方 + Task LoadAsync(string filePath); + + /// 按步骤顺序执行配方 + Task ExecuteAsync(InspectionRecipe recipe, CancellationToken cancellationToken = default); + + /// 暂停当前配方执行(当前步骤完成后停止) + void Pause(); + + /// 恢复已暂停的配方执行 + void Resume(); + } +} diff --git a/XplorePlane/Services/Recipe/RecipeService.cs b/XplorePlane/Services/Recipe/RecipeService.cs new file mode 100644 index 0000000..9f7e858 --- /dev/null +++ b/XplorePlane/Services/Recipe/RecipeService.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.Services.Recipe +{ + /// + /// 检测配方管理服务实现。 + /// 负责配方的创建、序列化/反序列化和执行编排,通过 ManualResetEventSlim 支持暂停/恢复。 + /// + public class RecipeService : IRecipeService + { + private readonly IAppStateService _appStateService; + private readonly IPipelineExecutionService _pipelineExecutionService; + private readonly ILogger _logger; + private readonly ManualResetEventSlim _pauseHandle = new(true); // initially signaled (not paused) + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + + public RecipeService( + IAppStateService appStateService, + IPipelineExecutionService pipelineExecutionService, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(appStateService); + ArgumentNullException.ThrowIfNull(pipelineExecutionService); + ArgumentNullException.ThrowIfNull(logger); + + _appStateService = appStateService; + _pipelineExecutionService = pipelineExecutionService; + _logger = logger; + + _logger.Information("RecipeService 已初始化"); + } + + /// + public InspectionRecipe CreateRecipe(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("配方名称不能为空", nameof(name)); + + var recipe = new InspectionRecipe( + Id: Guid.NewGuid(), + Name: name, + CreatedAt: DateTime.UtcNow, + UpdatedAt: DateTime.UtcNow, + Steps: new List().AsReadOnly()); + + _logger.Information("已创建配方: {RecipeName}, Id={RecipeId}", name, recipe.Id); + return recipe; + } + + /// + public RecipeStep RecordCurrentStep(InspectionRecipe recipe, PipelineModel pipeline) + { + ArgumentNullException.ThrowIfNull(recipe); + ArgumentNullException.ThrowIfNull(pipeline); + + var motionState = _appStateService.MotionState; + var raySourceState = _appStateService.RaySourceState; + var detectorState = _appStateService.DetectorState; + + var step = new RecipeStep( + StepIndex: recipe.Steps.Count, + MotionState: motionState, + RaySourceState: raySourceState, + DetectorState: detectorState, + Pipeline: pipeline); + + _logger.Information("已记录配方步骤: StepIndex={StepIndex}, Recipe={RecipeName}", + step.StepIndex, recipe.Name); + return step; + } + + /// + public async Task SaveAsync(InspectionRecipe recipe, string filePath) + { + ArgumentNullException.ThrowIfNull(recipe); + ArgumentNullException.ThrowIfNull(filePath); + + // 路径安全性验证:检查路径遍历 + if (filePath.Contains("..", StringComparison.Ordinal)) + throw new UnauthorizedAccessException($"文件路径包含不安全的路径遍历字符: {filePath}"); + + var json = JsonSerializer.Serialize(recipe, _jsonOptions); + await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + + _logger.Information("已保存配方到文件: {FilePath}, Recipe={RecipeName}", filePath, recipe.Name); + } + + /// + public async Task LoadAsync(string filePath) + { + ArgumentNullException.ThrowIfNull(filePath); + + if (!File.Exists(filePath)) + throw new FileNotFoundException($"配方文件不存在: {filePath}", filePath); + + string json; + try + { + json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not FileNotFoundException) + { + throw new InvalidDataException($"无法读取配方文件: {filePath}", ex); + } + + InspectionRecipe recipe; + try + { + recipe = JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (JsonException ex) + { + throw new InvalidDataException($"配方文件 JSON 格式无效: {filePath}", ex); + } + + if (recipe is null) + throw new InvalidDataException($"配方文件反序列化结果为 null: {filePath}"); + + if (string.IsNullOrWhiteSpace(recipe.Name)) + throw new InvalidDataException($"配方文件数据不完整,缺少名称: {filePath}"); + + if (recipe.Steps is null) + throw new InvalidDataException($"配方文件数据不完整,缺少步骤列表: {filePath}"); + + _logger.Information("已加载配方: {FilePath}, Recipe={RecipeName}, Steps={StepCount}", + filePath, recipe.Name, recipe.Steps.Count); + return recipe; + } + + /// + public async Task ExecuteAsync(InspectionRecipe recipe, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recipe); + + _logger.Information("开始执行配方: {RecipeName}, 共 {StepCount} 步", recipe.Name, recipe.Steps.Count); + + // 重置暂停状态 + _pauseHandle.Set(); + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: 0, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Running, + CurrentRecipeName: recipe.Name)); + + try + { + for (int i = 0; i < recipe.Steps.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + // 暂停检查:等待恢复信号 + _pauseHandle.Wait(cancellationToken); + + var step = recipe.Steps[i]; + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: i, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Running, + CurrentRecipeName: recipe.Name)); + + _logger.Information("执行配方步骤 {StepIndex}/{TotalSteps}: Recipe={RecipeName}", + i + 1, recipe.Steps.Count, recipe.Name); + + try + { + await ExecuteStepAsync(step, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // 让取消异常向上传播 + } + catch (Exception ex) + { + _logger.Error(ex, "配方步骤 {StepIndex} 执行失败: Recipe={RecipeName}", i, recipe.Name); + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: i, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Error, + CurrentRecipeName: recipe.Name)); + + _appStateService.UpdateSystemState(new SystemState( + OperationMode: OperationMode.Idle, + HasError: true, + ErrorMessage: $"配方 '{recipe.Name}' 步骤 {i} 执行失败: {ex.Message}")); + + return; + } + } + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: recipe.Steps.Count, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Completed, + CurrentRecipeName: recipe.Name)); + + _logger.Information("配方执行完成: {RecipeName}", recipe.Name); + } + catch (OperationCanceledException) + { + _logger.Information("配方执行已取消: {RecipeName}", recipe.Name); + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: 0, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Idle, + CurrentRecipeName: recipe.Name)); + + throw; + } + } + + /// + public void Pause() + { + _pauseHandle.Reset(); + + var current = _appStateService.RecipeExecutionState; + if (current.Status == RecipeExecutionStatus.Running) + { + _appStateService.UpdateRecipeExecutionState(current with + { + Status = RecipeExecutionStatus.Paused + }); + } + + _logger.Information("配方执行已暂停"); + } + + /// + public void Resume() + { + var current = _appStateService.RecipeExecutionState; + if (current.Status == RecipeExecutionStatus.Paused) + { + _appStateService.UpdateRecipeExecutionState(current with + { + Status = RecipeExecutionStatus.Running + }); + } + + _pauseHandle.Set(); + _logger.Information("配方执行已恢复"); + } + + // ── 内部辅助方法 ── + + private async Task ExecuteStepAsync(RecipeStep step, CancellationToken cancellationToken) + { + // 更新运动状态 + _appStateService.UpdateMotionState(step.MotionState); + + // 更新射线源状态 + _appStateService.UpdateRaySourceState(step.RaySourceState); + + // 更新探测器状态 + _appStateService.UpdateDetectorState(step.DetectorState); + + // TODO: 通过 IPipelineExecutionService 执行 Pipeline + // IPipelineExecutionService.ExecutePipelineAsync 需要 IEnumerable 和 BitmapSource, + // 而 RecipeStep 持有 PipelineModel。此处记录日志作为占位,待 ViewModel 层适配后完善。 + _logger.Information("步骤 {StepIndex}: 已更新设备状态,Pipeline '{PipelineName}' 执行待实现", + step.StepIndex, step.Pipeline?.Name ?? "N/A"); + + await Task.CompletedTask; + } + } +} diff --git a/XplorePlane/readme.txt b/XplorePlane/readme.txt index 077a576..0eaedc0 100644 --- a/XplorePlane/readme.txt +++ b/XplorePlane/readme.txt @@ -34,8 +34,9 @@ 2026.3.18 ---------------------- -1、全局数据结构的考虑与设计 -2、将计划窗体默认隐藏,只有CNC状态下展开 +1、全局数据结构的考虑与设计(多个窗体可以调用公共的数据,如射线源状态,探测器状态,运动位置,图像等) +2、将计划窗体默认隐藏,只有CNC状态下展开 √ +3、日志该用XP.Common库和多语言的学习