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 0000000..313f75f
Binary files /dev/null and b/XplorePlane/Assets/Icons/crosshair.png differ
diff --git a/XplorePlane/Assets/Icons/dynamic-range.png b/XplorePlane/Assets/Icons/dynamic-range.png
new file mode 100644
index 0000000..5fcee92
Binary files /dev/null and b/XplorePlane/Assets/Icons/dynamic-range.png differ
diff --git a/XplorePlane/Assets/Icons/film-darken.png b/XplorePlane/Assets/Icons/film-darken.png
new file mode 100644
index 0000000..605cd9c
Binary files /dev/null and b/XplorePlane/Assets/Icons/film-darken.png differ
diff --git a/XplorePlane/Assets/Icons/sharpen.png b/XplorePlane/Assets/Icons/sharpen.png
new file mode 100644
index 0000000..366616e
Binary files /dev/null and b/XplorePlane/Assets/Icons/sharpen.png differ
diff --git a/XplorePlane/Models/RecipeModels.cs b/XplorePlane/Models/RecipeModels.cs
new file mode 100644
index 0000000..3753ba5
--- /dev/null
+++ b/XplorePlane/Models/RecipeModels.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace XplorePlane.Models
+{
+ /// 检测配方中的单个步骤
+ 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库和多语言的学习