diff --git a/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json b/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json index 54eff8d..3f740dc 100644 --- a/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json +++ b/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json @@ -1878,10 +1878,7 @@ "Telerik.UI.for.Wpf.NetCore.Xaml": "2024.1.408" }, "runtime": { - "XP.Common.dll": { - "assemblyVersion": "1.4.16.1", - "fileVersion": "1.4.16.1" - } + "XP.Common.dll": {} }, "resources": { "en-US/XP.Common.resources.dll": { diff --git a/XplorePlane.Tests/Services/InspectionResultStoreTests.cs b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs new file mode 100644 index 0000000..640eeea --- /dev/null +++ b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs @@ -0,0 +1,340 @@ +using Moq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using XP.Common.Configs; +using XP.Common.Database.Implementations; +using XP.Common.Database.Interfaces; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.InspectionResults; +using Xunit; + +namespace XplorePlane.Tests.Services +{ + public class InspectionResultStoreTests : IDisposable + { + private readonly string _tempRoot; + private readonly Mock _mockLogger; + private readonly IDbContext _dbContext; + private readonly InspectionResultStore _store; + + public InspectionResultStoreTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "XplorePlane.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.ForModule(It.IsAny())).Returns(_mockLogger.Object); + _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); + + var sqliteConfig = new SqliteConfig + { + DbFilePath = Path.Combine(_tempRoot, "inspection-results.db"), + CreateIfNotExists = true, + EnableWalMode = false, + EnableSqlLogging = false + }; + + _dbContext = new SqliteContext(sqliteConfig, _mockLogger.Object); + _store = new InspectionResultStore(_dbContext, _mockLogger.Object, Path.Combine(_tempRoot, "assets")); + } + + [Fact] + public async Task FullRun_WithTwoNodes_CanRoundTripDetailAndQuery() + { + var startedAt = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + var run = new InspectionRunRecord + { + ProgramName = "NewCncProgram", + WorkpieceId = "QFN_1", + SerialNumber = "SN-001", + StartedAt = startedAt + }; + + var runSource = CreateTempFile("run-source.bmp", "run-source"); + await _store.BeginRunAsync(run, new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.RunSourceImage, + SourceFilePath = runSource, + FileFormat = "bmp" + }); + + var pipelineA = BuildPipeline("Recipe-A", ("GaussianBlur", 0), ("Threshold", 1)); + var node1Id = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = node1Id, + NodeIndex = 1, + NodeName = "检测节点1", + PipelineId = pipelineA.Id, + PipelineName = pipelineA.Name, + NodePass = true, + DurationMs = 135 + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "bridge.rate", + MetricName = "Bridge Rate", + MetricValue = 0.12, + Unit = "%", + UpperLimit = 0.2, + IsPass = true, + DisplayOrder = 1 + }, + new InspectionMetricResult + { + MetricKey = "void.area", + MetricName = "Void Area", + MetricValue = 5.6, + Unit = "px", + UpperLimit = 8, + IsPass = true, + DisplayOrder = 2 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = pipelineA.Name, + PipelineDefinitionJson = JsonSerializer.Serialize(pipelineA) + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeInputImage, + SourceFilePath = CreateTempFile("node1-input.bmp", "node1-input"), + FileFormat = "bmp" + }, + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = CreateTempFile("node1-result.bmp", "node1-result"), + FileFormat = "bmp" + } + }); + + var pipelineB = BuildPipeline("Recipe-B", ("MeanFilter", 0), ("ContourDetection", 1)); + var node2Id = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = node2Id, + NodeIndex = 2, + NodeName = "检测节点2", + PipelineId = pipelineB.Id, + PipelineName = pipelineB.Name, + NodePass = false, + Status = InspectionNodeStatus.Failed, + DurationMs = 240 + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "solder.height", + MetricName = "Solder Height", + MetricValue = 1.7, + Unit = "mm", + LowerLimit = 1.8, + IsPass = false, + DisplayOrder = 1 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = pipelineB.Name, + PipelineDefinitionJson = JsonSerializer.Serialize(pipelineB) + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = CreateTempFile("node2-result.bmp", "node2-result"), + FileFormat = "bmp" + } + }); + + await _store.CompleteRunAsync(run.RunId); + + var queried = await _store.QueryRunsAsync(new InspectionRunQuery + { + ProgramName = "NewCncProgram", + WorkpieceId = "QFN_1", + PipelineName = "Recipe-A" + }); + + var detail = await _store.GetRunDetailAsync(run.RunId); + + Assert.Single(queried); + Assert.Equal(run.RunId, queried[0].RunId); + Assert.False(detail.Run.OverallPass); + Assert.Equal(2, detail.Run.NodeCount); + Assert.Equal(2, detail.Nodes.Count); + Assert.Equal(3, detail.Metrics.Count); + Assert.Equal(4, detail.Assets.Count); + Assert.Equal(2, detail.PipelineSnapshots.Count); + Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass); + Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass); + Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash))); + + var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json"); + Assert.True(File.Exists(manifestPath)); + } + + [Fact] + public async Task AppendNodeResult_MissingAsset_DoesNotCrashAndMarksAssetMissing() + { + var run = new InspectionRunRecord + { + ProgramName = "Program-A", + WorkpieceId = "Part-01", + SerialNumber = "SN-404" + }; + + await _store.BeginRunAsync(run); + + var nodeId = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = nodeId, + NodeIndex = 1, + NodeName = "缺图节点", + PipelineId = Guid.NewGuid(), + PipelineName = "Recipe-Missing", + NodePass = true + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "metric.only", + MetricName = "Metric Only", + MetricValue = 1, + Unit = "pcs", + IsPass = true, + DisplayOrder = 1 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = "Recipe-Missing", + PipelineDefinitionJson = "{\"nodes\":[\"gaussian\"]}" + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = Path.Combine(_tempRoot, "missing-file.bmp"), + FileFormat = "bmp" + } + }); + + var detail = await _store.GetRunDetailAsync(run.RunId); + var node = Assert.Single(detail.Nodes); + + Assert.Equal(InspectionNodeStatus.AssetMissing, node.Status); + Assert.Single(detail.Metrics); + Assert.Empty(detail.Assets); + } + + [Fact] + public async Task PipelineSnapshot_IsStoredAsExecutionSnapshot_NotDependentOnLaterChanges() + { + var run = new InspectionRunRecord + { + ProgramName = "Program-Snapshot", + WorkpieceId = "Part-02", + SerialNumber = "SN-SNAP" + }; + + await _store.BeginRunAsync(run); + + var pipeline = BuildPipeline("Recipe-Snapshot", ("GaussianBlur", 0), ("ContourDetection", 1)); + var snapshotJson = JsonSerializer.Serialize(pipeline); + var originalHash = ComputeExpectedHash(snapshotJson); + + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = Guid.NewGuid(), + NodeIndex = 1, + NodeName = "快照节点", + PipelineId = pipeline.Id, + PipelineName = pipeline.Name, + NodePass = true + }, + pipelineSnapshot: new PipelineExecutionSnapshot + { + PipelineName = pipeline.Name, + PipelineDefinitionJson = snapshotJson + }); + + pipeline.Name = "Recipe-Snapshot-Changed"; + pipeline.Nodes[0].OperatorKey = "MeanFilter"; + + var detail = await _store.GetRunDetailAsync(run.RunId); + var snapshot = Assert.Single(detail.PipelineSnapshots); + + Assert.Equal("Recipe-Snapshot", snapshot.PipelineName); + Assert.Equal(snapshotJson, snapshot.PipelineDefinitionJson); + Assert.Equal(originalHash, snapshot.PipelineHash); + } + + public void Dispose() + { + _dbContext.Dispose(); + if (Directory.Exists(_tempRoot)) + { + try + { + Directory.Delete(_tempRoot, true); + } + catch (IOException) + { + // SQLite file handles may release slightly after test teardown. + } + } + } + + private string CreateTempFile(string fileName, string content) + { + var path = Path.Combine(_tempRoot, fileName); + File.WriteAllText(path, content); + return path; + } + + private static PipelineModel BuildPipeline(string name, params (string OperatorKey, int Order)[] nodes) + { + return new PipelineModel + { + Name = name, + Nodes = nodes.Select(node => new PipelineNodeModel + { + OperatorKey = node.OperatorKey, + Order = node.Order + }).ToList() + }; + } + + private static string ComputeExpectedHash(string value) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + return Convert.ToHexString(sha.ComputeHash(bytes)); + } + } +} diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj index ae65b4a..c55372f 100644 --- a/XplorePlane.Tests/XplorePlane.Tests.csproj +++ b/XplorePlane.Tests/XplorePlane.Tests.csproj @@ -34,11 +34,4 @@ - - - ..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll - True - - - diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index edc49f9..bd10aa4 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -37,6 +37,7 @@ using XplorePlane.Services.Camera; using XplorePlane.Services.Cnc; using XplorePlane.Services.Matrix; using XplorePlane.Services.Measurement; +using XplorePlane.Services.InspectionResults; using XplorePlane.Services.Recipe; using XplorePlane.ViewModels; using XplorePlane.ViewModels.Cnc; @@ -382,6 +383,7 @@ namespace XplorePlane containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); // ── CNC / 矩阵 ViewModel(瞬态)── containerRegistry.Register(); @@ -419,4 +421,4 @@ namespace XplorePlane base.ConfigureModuleCatalog(moduleCatalog); } } -} \ No newline at end of file +} diff --git a/XplorePlane/GapInspect.ico b/XplorePlane/GapInspect.ico deleted file mode 100644 index 53ae163..0000000 Binary files a/XplorePlane/GapInspect.ico and /dev/null differ diff --git a/XplorePlane/Models/CncModels.cs b/XplorePlane/Models/CncModels.cs index 5672544..56c1014 100644 --- a/XplorePlane/Models/CncModels.cs +++ b/XplorePlane/Models/CncModels.cs @@ -46,7 +46,8 @@ namespace XplorePlane.Models /// 参考点节点 | Reference point node public record ReferencePointNode( Guid Id, int Index, string Name, - double XM, double YM, double ZT, double ZD, double TiltD, double Dist + double XM, double YM, double ZT, double ZD, double TiltD, double Dist, + bool IsRayOn, double Voltage, double Current ) : CncNode(Id, Index, CncNodeType.ReferencePoint, Name); /// 保存节点(含图像)| Save node with image @@ -113,4 +114,4 @@ namespace XplorePlane.Models DateTime UpdatedAt, IReadOnlyList Nodes ); -} \ No newline at end of file +} diff --git a/XplorePlane/Models/InspectionResultModels.cs b/XplorePlane/Models/InspectionResultModels.cs new file mode 100644 index 0000000..9f439b2 --- /dev/null +++ b/XplorePlane/Models/InspectionResultModels.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace XplorePlane.Models +{ + public enum InspectionAssetType + { + RunSourceImage, + NodeInputImage, + NodeResultImage + } + + public enum InspectionNodeStatus + { + Succeeded, + Failed, + PartialSuccess, + AssetMissing + } + + public class InspectionRunRecord + { + public Guid RunId { get; set; } = Guid.NewGuid(); + public string ProgramName { get; set; } = string.Empty; + public string WorkpieceId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + public bool OverallPass { get; set; } + public string SourceImagePath { get; set; } = string.Empty; + public string ResultRootPath { get; set; } = string.Empty; + public int NodeCount { get; set; } + } + + public class InspectionNodeResult + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } = Guid.NewGuid(); + public int NodeIndex { get; set; } + public string NodeName { get; set; } = string.Empty; + public Guid PipelineId { get; set; } + public string PipelineName { get; set; } = string.Empty; + public string PipelineVersionHash { get; set; } = string.Empty; + public bool NodePass { get; set; } + public string SourceImagePath { get; set; } = string.Empty; + public string ResultImagePath { get; set; } = string.Empty; + public InspectionNodeStatus Status { get; set; } = InspectionNodeStatus.Succeeded; + public long DurationMs { get; set; } + } + + public class InspectionMetricResult + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public string MetricKey { get; set; } = string.Empty; + public string MetricName { get; set; } = string.Empty; + public double MetricValue { get; set; } + public string Unit { get; set; } = string.Empty; + public double? LowerLimit { get; set; } + public double? UpperLimit { get; set; } + public bool IsPass { get; set; } + public int DisplayOrder { get; set; } + } + + public class InspectionAssetRecord + { + public Guid RunId { get; set; } + public Guid? NodeId { get; set; } + public InspectionAssetType AssetType { get; set; } + public string RelativePath { get; set; } = string.Empty; + public string FileFormat { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } + } + + public class PipelineExecutionSnapshot + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public string PipelineName { get; set; } = string.Empty; + public string PipelineDefinitionJson { get; set; } = string.Empty; + public string PipelineHash { get; set; } = string.Empty; + } + + public class InspectionAssetWriteRequest + { + public InspectionAssetType AssetType { get; set; } + public string FileName { get; set; } = string.Empty; + public string SourceFilePath { get; set; } = string.Empty; + public byte[] Content { get; set; } + public string FileFormat { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } + } + + public class InspectionRunQuery + { + public string ProgramName { get; set; } = string.Empty; + public string WorkpieceId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public string PipelineName { get; set; } = string.Empty; + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public int? Skip { get; set; } + public int? Take { get; set; } + } + + public class InspectionRunDetail + { + public InspectionRunRecord Run { get; set; } = new(); + public IReadOnlyList Nodes { get; set; } = Array.Empty(); + public IReadOnlyList Metrics { get; set; } = Array.Empty(); + public IReadOnlyList Assets { get; set; } = Array.Empty(); + public IReadOnlyList PipelineSnapshots { get; set; } = Array.Empty(); + } +} diff --git a/XplorePlane/Services/Cnc/CncProgramService.cs b/XplorePlane/Services/Cnc/CncProgramService.cs index c7f9524..f4fa478 100644 --- a/XplorePlane/Services/Cnc/CncProgramService.cs +++ b/XplorePlane/Services/Cnc/CncProgramService.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using XP.Common.Logging.Interfaces; +using XP.Hardware.RaySource.Services; using XplorePlane.Models; using XplorePlane.Services.AppState; @@ -20,6 +21,7 @@ namespace XplorePlane.Services.Cnc public class CncProgramService : ICncProgramService { private readonly IAppStateService _appStateService; + private readonly IRaySourceService _raySourceService; private readonly ILoggerService _logger; // ── 序列化配置 | Serialization options ── @@ -32,12 +34,15 @@ namespace XplorePlane.Services.Cnc public CncProgramService( IAppStateService appStateService, + IRaySourceService raySourceService, ILoggerService logger) { ArgumentNullException.ThrowIfNull(appStateService); + ArgumentNullException.ThrowIfNull(raySourceService); ArgumentNullException.ThrowIfNull(logger); _appStateService = appStateService; + _raySourceService = raySourceService; _logger = logger.ForModule(); _logger.Info("CncProgramService 已初始化 | CncProgramService initialized"); @@ -200,6 +205,32 @@ namespace XplorePlane.Services.Cnc return updated; } + /// + public CncProgram UpdateNode(CncProgram program, int index, CncNode node) + { + ArgumentNullException.ThrowIfNull(program); + ArgumentNullException.ThrowIfNull(node); + + if (index < 0 || index >= program.Nodes.Count) + throw new ArgumentOutOfRangeException(nameof(index), + $"Index out of range: {index}, Count={program.Nodes.Count}"); + + var nodes = new List(program.Nodes) + { + [index] = node with { Index = index } + }; + + var updated = program with + { + Nodes = nodes.AsReadOnly(), + UpdatedAt = DateTime.UtcNow + }; + + _logger.Info("Updated node: Index={Index}, Type={NodeType}, Program={ProgramName}", + index, node.NodeType, program.Name); + return updated; + } + /// public async Task SaveAsync(CncProgram program, string filePath) { @@ -344,6 +375,7 @@ namespace XplorePlane.Services.Cnc private ReferencePointNode CreateReferencePointNode(Guid id, int index) { var motion = _appStateService.MotionState; + var raySource = _appStateService.RaySourceState; return new ReferencePointNode( id, index, $"参考点_{index}", XM: motion.XM, @@ -351,7 +383,10 @@ namespace XplorePlane.Services.Cnc ZT: motion.ZT, ZD: motion.ZD, TiltD: motion.TiltD, - Dist: motion.Dist); + Dist: motion.Dist, + IsRayOn: raySource.IsOn, + Voltage: raySource.Voltage, + Current: TryReadCurrent()); } /// 创建保存节点(含图像)| Create save node with image @@ -382,5 +417,24 @@ namespace XplorePlane.Services.Cnc id, index, $"保存位置_{index}", MotionState: _appStateService.MotionState); } + private double TryReadCurrent() + { + try + { + var result = _raySourceService.ReadCurrent(); + if (result?.Success == true) + { + return result.GetFloat(); + } + + _logger.Warn("Failed to read ray source current, ReferencePoint node will use 0"); + } + catch (Exception ex) + { + _logger.Warn("Failed to read ray source current: {Message}", ex.Message); + } + + return 0d; + } } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/Cnc/ICncProgramService.cs b/XplorePlane/Services/Cnc/ICncProgramService.cs index 8429f96..db54c03 100644 --- a/XplorePlane/Services/Cnc/ICncProgramService.cs +++ b/XplorePlane/Services/Cnc/ICncProgramService.cs @@ -4,36 +4,28 @@ using XplorePlane.Models; namespace XplorePlane.Services.Cnc { /// - /// CNC 程序管理服务接口,负责程序的创建、节点编辑、序列化/反序列化和文件读写 - /// CNC program management service interface for creation, node editing, serialization and file I/O + /// CNC program management service interface. /// public interface ICncProgramService { - /// 创建空的 CNC 程序 | Create an empty CNC program CncProgram CreateProgram(string name); - /// 根据节点类型创建节点(从 IAppStateService 捕获设备状态)| Create a node by type (captures device state from IAppStateService) CncNode CreateNode(CncNodeType type); - /// 在指定索引之后插入节点并重新编号 | Insert a node after the given index and renumber CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node); - /// 移除指定索引的节点并重新编号 | Remove the node at the given index and renumber CncProgram RemoveNode(CncProgram program, int index); - /// 将节点从旧索引移动到新索引并重新编号 | Move a node from old index to new index and renumber CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex); - /// 将 CNC 程序保存到 .xp 文件 | Save CNC program to .xp file + CncProgram UpdateNode(CncProgram program, int index, CncNode node); + Task SaveAsync(CncProgram program, string filePath); - /// 从 .xp 文件加载 CNC 程序 | Load CNC program from .xp file Task LoadAsync(string filePath); - /// 将 CNC 程序序列化为 JSON 字符串 | Serialize CNC program to JSON string string Serialize(CncProgram program); - /// 从 JSON 字符串反序列化 CNC 程序 | Deserialize CNC program from JSON string CncProgram Deserialize(string json); } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs new file mode 100644 index 0000000..7944723 --- /dev/null +++ b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.InspectionResults +{ + public interface IInspectionResultStore + { + Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null); + + Task AppendNodeResultAsync( + InspectionNodeResult nodeResult, + IEnumerable metrics = null, + PipelineExecutionSnapshot pipelineSnapshot = null, + IEnumerable assets = null); + + Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null); + + Task> QueryRunsAsync(InspectionRunQuery query = null); + + Task GetRunDetailAsync(Guid runId); + } +} diff --git a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs new file mode 100644 index 0000000..88c046f --- /dev/null +++ b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs @@ -0,0 +1,1006 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using XP.Common.Database.Interfaces; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; + +namespace XplorePlane.Services.InspectionResults +{ + public class InspectionResultStore : IInspectionResultStore + { + private readonly IDbContext _db; + private readonly ILoggerService _logger; + private readonly string _baseDirectory; + private bool _initialized; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private const string CreateTableSql = @" +CREATE TABLE IF NOT EXISTS inspection_runs ( + run_id TEXT PRIMARY KEY, + program_name TEXT NOT NULL, + workpiece_id TEXT NOT NULL, + serial_number TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT NULL, + overall_pass INTEGER NOT NULL DEFAULT 0, + source_image_path TEXT NOT NULL, + result_root_path TEXT NOT NULL, + node_count INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_started_at ON inspection_runs(started_at); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_program_name ON inspection_runs(program_name); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_workpiece_id ON inspection_runs(workpiece_id); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_serial_number ON inspection_runs(serial_number); + +CREATE TABLE IF NOT EXISTS inspection_node_results ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + node_index INTEGER NOT NULL, + node_name TEXT NOT NULL, + pipeline_id TEXT NOT NULL, + pipeline_name TEXT NOT NULL, + pipeline_version_hash TEXT NOT NULL, + node_pass INTEGER NOT NULL DEFAULT 0, + source_image_path TEXT NOT NULL, + result_image_path TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id) +); +CREATE INDEX IF NOT EXISTS idx_inspection_nodes_run_id ON inspection_node_results(run_id); +CREATE INDEX IF NOT EXISTS idx_inspection_nodes_pipeline_name ON inspection_node_results(pipeline_name); + +CREATE TABLE IF NOT EXISTS inspection_metric_results ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + metric_key TEXT NOT NULL, + metric_name TEXT NOT NULL, + metric_value REAL NOT NULL, + unit TEXT NOT NULL, + lower_limit REAL NULL, + upper_limit REAL NULL, + is_pass INTEGER NOT NULL DEFAULT 0, + display_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id, metric_key) +); +CREATE INDEX IF NOT EXISTS idx_inspection_metrics_run_node ON inspection_metric_results(run_id, node_id); + +CREATE TABLE IF NOT EXISTS inspection_assets ( + run_id TEXT NOT NULL, + node_id TEXT NULL, + asset_type TEXT NOT NULL, + relative_path TEXT NOT NULL, + file_format TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id, asset_type, relative_path) +); +CREATE INDEX IF NOT EXISTS idx_inspection_assets_run_node ON inspection_assets(run_id, node_id); + +CREATE TABLE IF NOT EXISTS pipeline_execution_snapshots ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + pipeline_name TEXT NOT NULL, + pipeline_definition_json TEXT NOT NULL, + pipeline_hash TEXT NOT NULL, + PRIMARY KEY (run_id, node_id) +); +CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id);"; + + private const string InsertRunSql = @" +INSERT INTO inspection_runs ( + run_id, program_name, workpiece_id, serial_number, started_at, completed_at, + overall_pass, source_image_path, result_root_path, node_count) +VALUES ( + @run_id, @program_name, @workpiece_id, @serial_number, @started_at, @completed_at, + @overall_pass, @source_image_path, @result_root_path, @node_count)"; + + private const string InsertNodeSql = @" +INSERT OR REPLACE INTO inspection_node_results ( + run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, + node_pass, source_image_path, result_image_path, status, duration_ms) +VALUES ( + @run_id, @node_id, @node_index, @node_name, @pipeline_id, @pipeline_name, @pipeline_version_hash, + @node_pass, @source_image_path, @result_image_path, @status, @duration_ms)"; + + private const string InsertMetricSql = @" +INSERT OR REPLACE INTO inspection_metric_results ( + run_id, node_id, metric_key, metric_name, metric_value, unit, + lower_limit, upper_limit, is_pass, display_order) +VALUES ( + @run_id, @node_id, @metric_key, @metric_name, @metric_value, @unit, + @lower_limit, @upper_limit, @is_pass, @display_order)"; + + private const string InsertAssetSql = @" +INSERT OR REPLACE INTO inspection_assets ( + run_id, node_id, asset_type, relative_path, file_format, width, height) +VALUES ( + @run_id, @node_id, @asset_type, @relative_path, @file_format, @width, @height)"; + + private const string InsertSnapshotSql = @" +INSERT OR REPLACE INTO pipeline_execution_snapshots ( + run_id, node_id, pipeline_name, pipeline_definition_json, pipeline_hash) +VALUES ( + @run_id, @node_id, @pipeline_name, @pipeline_definition_json, @pipeline_hash)"; + + private const string DeleteNodeMetricsSql = "DELETE FROM inspection_metric_results WHERE run_id = @run_id AND node_id = @node_id"; + private const string DeleteNodeAssetsSql = "DELETE FROM inspection_assets WHERE run_id = @run_id AND node_id = @node_id"; + + private const string UpdateRunSql = @" +UPDATE inspection_runs +SET completed_at = @completed_at, + overall_pass = @overall_pass, + node_count = @node_count, + source_image_path = @source_image_path +WHERE run_id = @run_id"; + + public InspectionResultStore(IDbContext db, ILoggerService logger, string baseDirectory = null) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(logger); + + _db = db; + _logger = logger.ForModule(); + _baseDirectory = baseDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", + "InspectionResults"); + } + + public async Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null) + { + ArgumentNullException.ThrowIfNull(runRecord); + await EnsureInitializedAsync().ConfigureAwait(false); + + NormalizeRunRecord(runRecord); + ValidateRunRecord(runRecord); + + var runDirectory = GetRunDirectory(runRecord.StartedAt, runRecord.RunId); + Directory.CreateDirectory(Path.Combine(runDirectory, "run")); + Directory.CreateDirectory(Path.Combine(runDirectory, "nodes")); + + if (runSourceAsset != null) + { + var savedAsset = await TryPersistAssetAsync( + runRecord.RunId, + null, + nodeIndex: null, + nodeName: null, + runRecord.ResultRootPath, + runSourceAsset).ConfigureAwait(false); + + if (savedAsset != null) + { + runRecord.SourceImagePath = savedAsset.RelativePath; + await SaveAssetRecordAsync(savedAsset).ConfigureAwait(false); + } + } + + var result = await _db.ExecuteNonQueryAsync(InsertRunSql, new Dictionary + { + ["run_id"] = runRecord.RunId.ToString("D"), + ["program_name"] = runRecord.ProgramName, + ["workpiece_id"] = runRecord.WorkpieceId, + ["serial_number"] = runRecord.SerialNumber, + ["started_at"] = runRecord.StartedAt.ToString("o"), + ["completed_at"] = runRecord.CompletedAt?.ToString("o"), + ["overall_pass"] = runRecord.OverallPass ? 1 : 0, + ["source_image_path"] = runRecord.SourceImagePath, + ["result_root_path"] = runRecord.ResultRootPath, + ["node_count"] = runRecord.NodeCount + }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "创建检测实例失败 | Failed to create inspection run: {Message}", result.Message); + throw new InvalidOperationException($"创建检测实例失败: {result.Message}", result.Exception); + } + } + + public async Task AppendNodeResultAsync( + InspectionNodeResult nodeResult, + IEnumerable metrics = null, + PipelineExecutionSnapshot pipelineSnapshot = null, + IEnumerable assets = null) + { + ArgumentNullException.ThrowIfNull(nodeResult); + await EnsureInitializedAsync().ConfigureAwait(false); + ValidateNodeResult(nodeResult); + await EnsureRunExistsAsync(nodeResult.RunId).ConfigureAwait(false); + + var assetRecords = new List(); + var assetFailureMode = (InspectionNodeStatus?)null; + foreach (var asset in assets ?? Enumerable.Empty()) + { + var savedAsset = await TryPersistAssetAsync( + nodeResult.RunId, + nodeResult.NodeId, + nodeResult.NodeIndex, + nodeResult.NodeName, + await GetRunRootRelativePathAsync(nodeResult.RunId).ConfigureAwait(false), + asset).ConfigureAwait(false); + + if (savedAsset == null) + { + assetFailureMode ??= ResolveAssetFailureMode(asset); + continue; + } + + assetRecords.Add(savedAsset); + + if (asset.AssetType == InspectionAssetType.NodeInputImage && string.IsNullOrWhiteSpace(nodeResult.SourceImagePath)) + { + nodeResult.SourceImagePath = savedAsset.RelativePath; + } + + if (asset.AssetType == InspectionAssetType.NodeResultImage && string.IsNullOrWhiteSpace(nodeResult.ResultImagePath)) + { + nodeResult.ResultImagePath = savedAsset.RelativePath; + } + } + + if (pipelineSnapshot != null) + { + pipelineSnapshot.RunId = nodeResult.RunId; + pipelineSnapshot.NodeId = nodeResult.NodeId; + if (string.IsNullOrWhiteSpace(pipelineSnapshot.PipelineName)) + { + pipelineSnapshot.PipelineName = nodeResult.PipelineName; + } + + if (string.IsNullOrWhiteSpace(pipelineSnapshot.PipelineHash)) + { + pipelineSnapshot.PipelineHash = ComputeSha256(pipelineSnapshot.PipelineDefinitionJson); + } + + if (string.IsNullOrWhiteSpace(nodeResult.PipelineVersionHash)) + { + nodeResult.PipelineVersionHash = pipelineSnapshot.PipelineHash; + } + } + + if (string.IsNullOrWhiteSpace(nodeResult.PipelineVersionHash)) + { + nodeResult.PipelineVersionHash = ComputeSha256(nodeResult.PipelineName); + } + + if (assetFailureMode.HasValue && nodeResult.Status == InspectionNodeStatus.Succeeded) + { + nodeResult.Status = assetFailureMode.Value; + } + + var metricsList = (metrics ?? Enumerable.Empty()) + .Select(metric => + { + metric.RunId = nodeResult.RunId; + metric.NodeId = nodeResult.NodeId; + return metric; + }) + .ToList(); + + await _db.ExecuteNonQueryAsync(DeleteNodeMetricsSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D") + }).ConfigureAwait(false); + + await _db.ExecuteNonQueryAsync(DeleteNodeAssetsSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D") + }).ConfigureAwait(false); + + var saveNode = await _db.ExecuteNonQueryAsync(InsertNodeSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D"), + ["node_index"] = nodeResult.NodeIndex, + ["node_name"] = nodeResult.NodeName, + ["pipeline_id"] = nodeResult.PipelineId.ToString("D"), + ["pipeline_name"] = nodeResult.PipelineName, + ["pipeline_version_hash"] = nodeResult.PipelineVersionHash, + ["node_pass"] = nodeResult.NodePass ? 1 : 0, + ["source_image_path"] = nodeResult.SourceImagePath, + ["result_image_path"] = nodeResult.ResultImagePath, + ["status"] = nodeResult.Status.ToString(), + ["duration_ms"] = nodeResult.DurationMs + }).ConfigureAwait(false); + + if (!saveNode.IsSuccess) + { + _logger.Error(saveNode.Exception!, "保存节点检测结果失败 | Failed to save inspection node result: {Message}", saveNode.Message); + throw new InvalidOperationException($"保存节点检测结果失败: {saveNode.Message}", saveNode.Exception); + } + + foreach (var metric in metricsList) + { + var metricResult = await _db.ExecuteNonQueryAsync(InsertMetricSql, new Dictionary + { + ["run_id"] = metric.RunId.ToString("D"), + ["node_id"] = metric.NodeId.ToString("D"), + ["metric_key"] = metric.MetricKey, + ["metric_name"] = metric.MetricName, + ["metric_value"] = metric.MetricValue, + ["unit"] = metric.Unit, + ["lower_limit"] = metric.LowerLimit, + ["upper_limit"] = metric.UpperLimit, + ["is_pass"] = metric.IsPass ? 1 : 0, + ["display_order"] = metric.DisplayOrder + }).ConfigureAwait(false); + + if (!metricResult.IsSuccess) + { + _logger.Error(metricResult.Exception!, "保存检测指标失败 | Failed to save inspection metric: {Message}", metricResult.Message); + throw new InvalidOperationException($"保存检测指标失败: {metricResult.Message}", metricResult.Exception); + } + } + + foreach (var assetRecord in assetRecords) + { + await SaveAssetRecordAsync(assetRecord).ConfigureAwait(false); + } + + if (pipelineSnapshot != null) + { + var snapshotResult = await _db.ExecuteNonQueryAsync(InsertSnapshotSql, new Dictionary + { + ["run_id"] = pipelineSnapshot.RunId.ToString("D"), + ["node_id"] = pipelineSnapshot.NodeId.ToString("D"), + ["pipeline_name"] = pipelineSnapshot.PipelineName, + ["pipeline_definition_json"] = pipelineSnapshot.PipelineDefinitionJson, + ["pipeline_hash"] = pipelineSnapshot.PipelineHash + }).ConfigureAwait(false); + + if (!snapshotResult.IsSuccess) + { + _logger.Error(snapshotResult.Exception!, "保存流水线快照失败 | Failed to save pipeline snapshot: {Message}", snapshotResult.Message); + throw new InvalidOperationException($"保存流水线快照失败: {snapshotResult.Message}", snapshotResult.Exception); + } + } + } + + public async Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + await EnsureRunExistsAsync(runId).ConfigureAwait(false); + + var detail = await GetRunDetailAsync(runId).ConfigureAwait(false); + var run = detail.Run; + run.CompletedAt = completedAt ?? DateTime.UtcNow; + run.NodeCount = detail.Nodes.Count; + run.OverallPass = overallPass ?? detail.Nodes.All(node => node.NodePass); + + var update = await _db.ExecuteNonQueryAsync(UpdateRunSql, new Dictionary + { + ["run_id"] = run.RunId.ToString("D"), + ["completed_at"] = run.CompletedAt?.ToString("o"), + ["overall_pass"] = run.OverallPass ? 1 : 0, + ["node_count"] = run.NodeCount, + ["source_image_path"] = run.SourceImagePath + }).ConfigureAwait(false); + + if (!update.IsSuccess) + { + _logger.Error(update.Exception!, "完成检测实例失败 | Failed to complete inspection run: {Message}", update.Message); + throw new InvalidOperationException($"完成检测实例失败: {update.Message}", update.Exception); + } + + detail.Run = run; + await WriteManifestAsync(detail).ConfigureAwait(false); + } + + public async Task> QueryRunsAsync(InspectionRunQuery query = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + query ??= new InspectionRunQuery(); + + var sql = new StringBuilder(); + sql.Append("SELECT DISTINCT r.run_id, r.program_name, r.workpiece_id, r.serial_number, r.started_at, r.completed_at, r.overall_pass, r.source_image_path, r.result_root_path, r.node_count FROM inspection_runs r"); + var conditions = new List(); + var parameters = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(query.PipelineName)) + { + sql.Append(" INNER JOIN inspection_node_results n ON n.run_id = r.run_id"); + conditions.Add("n.pipeline_name = @pipeline_name"); + parameters["pipeline_name"] = query.PipelineName; + } + + if (!string.IsNullOrWhiteSpace(query.ProgramName)) + { + conditions.Add("r.program_name = @program_name"); + parameters["program_name"] = query.ProgramName; + } + + if (!string.IsNullOrWhiteSpace(query.WorkpieceId)) + { + conditions.Add("r.workpiece_id = @workpiece_id"); + parameters["workpiece_id"] = query.WorkpieceId; + } + + if (!string.IsNullOrWhiteSpace(query.SerialNumber)) + { + conditions.Add("r.serial_number = @serial_number"); + parameters["serial_number"] = query.SerialNumber; + } + + if (query.From.HasValue) + { + conditions.Add("r.started_at >= @from"); + parameters["from"] = query.From.Value.ToString("o"); + } + + if (query.To.HasValue) + { + conditions.Add("r.started_at <= @to"); + parameters["to"] = query.To.Value.ToString("o"); + } + + if (conditions.Count > 0) + { + sql.Append(" WHERE "); + sql.Append(string.Join(" AND ", conditions)); + } + + sql.Append(" ORDER BY r.started_at DESC"); + + if (query.Take.HasValue) + { + sql.Append(" LIMIT @take"); + parameters["take"] = query.Take.Value; + + if (query.Skip.GetValueOrDefault() > 0) + { + sql.Append(" OFFSET @skip"); + parameters["skip"] = query.Skip.Value; + } + } + + var (result, data) = await _db.QueryListAsync(sql.ToString(), parameters).ConfigureAwait(false); + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "查询检测实例失败 | Failed to query inspection runs: {Message}", result.Message); + throw new InvalidOperationException($"查询检测实例失败: {result.Message}", result.Exception); + } + + return data.Select(MapRun).ToList().AsReadOnly(); + } + + public async Task GetRunDetailAsync(Guid runId) + { + await EnsureInitializedAsync().ConfigureAwait(false); + + var (runResult, runs) = await _db.QueryListAsync( + "SELECT run_id, program_name, workpiece_id, serial_number, started_at, completed_at, overall_pass, source_image_path, result_root_path, node_count FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!runResult.IsSuccess) + { + _logger.Error(runResult.Exception!, "加载检测实例失败 | Failed to load inspection run: {Message}", runResult.Message); + throw new InvalidOperationException($"加载检测实例失败: {runResult.Message}", runResult.Exception); + } + + if (runs.Count == 0) + { + throw new InvalidOperationException($"检测实例不存在: {runId}"); + } + + var run = MapRun(runs[0]); + + var (nodeResult, nodeRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (metricResult, metricRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, metric_key, metric_name, metric_value, unit, COALESCE(CAST(lower_limit AS TEXT), '') AS lower_limit, COALESCE(CAST(upper_limit AS TEXT), '') AS upper_limit, is_pass, display_order FROM inspection_metric_results WHERE run_id = @run_id ORDER BY node_id ASC, display_order ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (assetResult, assetRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, asset_type, relative_path, file_format, width, height FROM inspection_assets WHERE run_id = @run_id ORDER BY node_id ASC, asset_type ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (snapshotResult, snapshotRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, pipeline_name, pipeline_definition_json, pipeline_hash FROM pipeline_execution_snapshots WHERE run_id = @run_id ORDER BY node_id ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + ThrowIfQueryFailed(nodeResult, "加载检测节点结果失败"); + ThrowIfQueryFailed(metricResult, "加载检测指标结果失败"); + ThrowIfQueryFailed(assetResult, "加载检测资产结果失败"); + ThrowIfQueryFailed(snapshotResult, "加载流水线快照失败"); + + return new InspectionRunDetail + { + Run = run, + Nodes = nodeRows.Select(MapNode).ToList().AsReadOnly(), + Metrics = metricRows.Select(MapMetric).ToList().AsReadOnly(), + Assets = assetRows.Select(MapAsset).ToList().AsReadOnly(), + PipelineSnapshots = snapshotRows.Select(MapSnapshot).ToList().AsReadOnly() + }; + } + + private async Task EnsureInitializedAsync() + { + if (_initialized) + { + return; + } + + Directory.CreateDirectory(_baseDirectory); + var result = await _db.ExecuteNonQueryAsync(CreateTableSql).ConfigureAwait(false); + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "初始化检测结果归档失败 | Failed to initialize inspection result store: {Message}", result.Message); + throw new InvalidOperationException($"初始化检测结果归档失败: {result.Message}", result.Exception); + } + + _initialized = true; + } + + private async Task EnsureRunExistsAsync(Guid runId) + { + var (result, value) = await _db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "校验检测实例失败 | Failed to validate inspection run: {Message}", result.Message); + throw new InvalidOperationException($"校验检测实例失败: {result.Message}", result.Exception); + } + + if (value <= 0) + { + throw new InvalidOperationException($"检测实例不存在: {runId}"); + } + } + + private async Task SaveAssetRecordAsync(InspectionAssetRecord assetRecord) + { + var saveAsset = await _db.ExecuteNonQueryAsync(InsertAssetSql, new Dictionary + { + ["run_id"] = assetRecord.RunId.ToString("D"), + ["node_id"] = assetRecord.NodeId?.ToString("D"), + ["asset_type"] = assetRecord.AssetType.ToString(), + ["relative_path"] = assetRecord.RelativePath, + ["file_format"] = assetRecord.FileFormat, + ["width"] = assetRecord.Width, + ["height"] = assetRecord.Height + }).ConfigureAwait(false); + + if (!saveAsset.IsSuccess) + { + _logger.Error(saveAsset.Exception!, "保存检测资产索引失败 | Failed to save inspection asset record: {Message}", saveAsset.Message); + throw new InvalidOperationException($"保存检测资产索引失败: {saveAsset.Message}", saveAsset.Exception); + } + } + + private async Task TryPersistAssetAsync( + Guid runId, + Guid? nodeId, + int? nodeIndex, + string nodeName, + string runRelativeRoot, + InspectionAssetWriteRequest assetRequest) + { + try + { + if (assetRequest == null) + { + return null; + } + + var bytes = assetRequest.Content; + if (bytes == null) + { + if (string.IsNullOrWhiteSpace(assetRequest.SourceFilePath) || !File.Exists(assetRequest.SourceFilePath)) + { + throw new FileNotFoundException("资产源文件不存在", assetRequest.SourceFilePath); + } + + bytes = await File.ReadAllBytesAsync(assetRequest.SourceFilePath).ConfigureAwait(false); + } + + var relativePath = BuildAssetRelativePath(runRelativeRoot, nodeIndex, nodeName, assetRequest); + var absolutePath = Path.Combine(_baseDirectory, relativePath); + var targetDirectory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + await File.WriteAllBytesAsync(absolutePath, bytes).ConfigureAwait(false); + + return new InspectionAssetRecord + { + RunId = runId, + NodeId = nodeId, + AssetType = assetRequest.AssetType, + RelativePath = relativePath.Replace('\\', '/'), + FileFormat = ResolveFileFormat(assetRequest), + Width = assetRequest.Width, + Height = assetRequest.Height + }; + } + catch (Exception ex) + { + _logger.Warn("检测资产保存失败 | Failed to persist inspection asset: {Message}", ex.Message); + return null; + } + } + + private async Task WriteManifestAsync(InspectionRunDetail detail) + { + var runDirectory = Path.Combine(_baseDirectory, detail.Run.ResultRootPath); + Directory.CreateDirectory(runDirectory); + var manifestPath = Path.Combine(runDirectory, "manifest.json"); + var json = JsonSerializer.Serialize(detail, JsonOptions); + await File.WriteAllTextAsync(manifestPath, json).ConfigureAwait(false); + } + + private string BuildAssetRelativePath(string runRelativeRoot, int? nodeIndex, string nodeName, InspectionAssetWriteRequest assetRequest) + { + if (assetRequest.AssetType == InspectionAssetType.RunSourceImage) + { + return Path.Combine(runRelativeRoot, "run", ResolveAssetFileName(assetRequest, "source")); + } + + var nodeFolder = $"{nodeIndex.GetValueOrDefault():D3}_{SanitizePathSegment(nodeName)}"; + return Path.Combine(runRelativeRoot, "nodes", nodeFolder, ResolveNodeAssetFileName(assetRequest)); + } + + private string ResolveNodeAssetFileName(InspectionAssetWriteRequest assetRequest) + { + var extension = ResolveExtension(assetRequest); + return assetRequest.AssetType switch + { + InspectionAssetType.NodeInputImage => $"input{extension}", + InspectionAssetType.NodeResultImage => $"result_overlay{extension}", + _ => ResolveAssetFileName(assetRequest, "asset") + }; + } + + private string ResolveAssetFileName(InspectionAssetWriteRequest assetRequest, string fallbackName) + { + var extension = ResolveExtension(assetRequest); + if (!string.IsNullOrWhiteSpace(assetRequest.FileName)) + { + return Path.GetFileName(assetRequest.FileName); + } + + return $"{fallbackName}{extension}"; + } + + private string ResolveExtension(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest.FileName)) + { + var extension = Path.GetExtension(assetRequest.FileName); + if (!string.IsNullOrWhiteSpace(extension)) + { + return extension; + } + } + + if (!string.IsNullOrWhiteSpace(assetRequest.SourceFilePath)) + { + var extension = Path.GetExtension(assetRequest.SourceFilePath); + if (!string.IsNullOrWhiteSpace(extension)) + { + return extension; + } + } + + if (!string.IsNullOrWhiteSpace(assetRequest.FileFormat)) + { + return "." + assetRequest.FileFormat.Trim().TrimStart('.'); + } + + return ".bin"; + } + + private string ResolveFileFormat(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest.FileFormat)) + { + return assetRequest.FileFormat.Trim().TrimStart('.'); + } + + var extension = ResolveExtension(assetRequest); + return extension.Trim().TrimStart('.'); + } + + private string GetRunDirectory(DateTime startedAt, Guid runId) + { + return Path.Combine(_baseDirectory, GetRunRelativeRoot(runId, startedAt)); + } + + private async Task GetRunRootRelativePathAsync(Guid runId) + { + var (result, value) = await _db.ExecuteScalarAsync( + "SELECT result_root_path FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "读取检测实例目录失败 | Failed to get inspection run root path: {Message}", result.Message); + throw new InvalidOperationException($"读取检测实例目录失败: {result.Message}", result.Exception); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"检测实例目录不存在: {runId}"); + } + + return value.Replace('\\', '/'); + } + + private string GetRunRelativeRoot(Guid runId, DateTime? startedAt = null) + { + if (startedAt.HasValue) + { + return Path.Combine( + "Results", + startedAt.Value.ToString("yyyy"), + startedAt.Value.ToString("MM"), + startedAt.Value.ToString("dd"), + runId.ToString("D")); + } + + return Path.Combine("Results", runId.ToString("D")); + } + + private static string ComputeSha256(string value) + { + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + var hash = sha.ComputeHash(bytes); + return Convert.ToHexString(hash); + } + + private static string SanitizePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "unnamed"; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + return sanitized.Replace(' ', '_'); + } + + private static InspectionNodeStatus ResolveAssetFailureMode(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest?.SourceFilePath) && !File.Exists(assetRequest.SourceFilePath)) + { + return InspectionNodeStatus.AssetMissing; + } + + return InspectionNodeStatus.PartialSuccess; + } + + private static void NormalizeRunRecord(InspectionRunRecord runRecord) + { + if (runRecord.RunId == Guid.Empty) + { + runRecord.RunId = Guid.NewGuid(); + } + + if (runRecord.StartedAt == default) + { + runRecord.StartedAt = DateTime.UtcNow; + } + + runRecord.ResultRootPath = Path.Combine( + "Results", + runRecord.StartedAt.ToString("yyyy"), + runRecord.StartedAt.ToString("MM"), + runRecord.StartedAt.ToString("dd"), + runRecord.RunId.ToString("D")).Replace('\\', '/'); + } + + private static void ValidateRunRecord(InspectionRunRecord runRecord) + { + if (string.IsNullOrWhiteSpace(runRecord.ProgramName)) + { + throw new ArgumentException("ProgramName 不能为空", nameof(runRecord)); + } + } + + private static void ValidateNodeResult(InspectionNodeResult nodeResult) + { + if (nodeResult.RunId == Guid.Empty) + { + throw new ArgumentException("NodeResult 必须带 RunId", nameof(nodeResult)); + } + + if (nodeResult.NodeId == Guid.Empty) + { + throw new ArgumentException("NodeResult 必须带 NodeId", nameof(nodeResult)); + } + + if (string.IsNullOrWhiteSpace(nodeResult.NodeName)) + { + throw new ArgumentException("NodeResult 必须带 NodeName", nameof(nodeResult)); + } + } + + private static InspectionRunRecord MapRun(InspectionRunRow row) + { + return new InspectionRunRecord + { + RunId = Guid.Parse(row.run_id), + ProgramName = row.program_name, + WorkpieceId = row.workpiece_id, + SerialNumber = row.serial_number, + StartedAt = DateTime.Parse(row.started_at, null, System.Globalization.DateTimeStyles.RoundtripKind), + CompletedAt = string.IsNullOrWhiteSpace(row.completed_at) + ? null + : DateTime.Parse(row.completed_at, null, System.Globalization.DateTimeStyles.RoundtripKind), + OverallPass = row.overall_pass != 0, + SourceImagePath = row.source_image_path, + ResultRootPath = row.result_root_path, + NodeCount = row.node_count + }; + } + + private static InspectionNodeResult MapNode(InspectionNodeRow row) + { + _ = Enum.TryParse(row.status, out var status); + return new InspectionNodeResult + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + NodeIndex = row.node_index, + NodeName = row.node_name, + PipelineId = Guid.Parse(row.pipeline_id), + PipelineName = row.pipeline_name, + PipelineVersionHash = row.pipeline_version_hash, + NodePass = row.node_pass != 0, + SourceImagePath = row.source_image_path, + ResultImagePath = row.result_image_path, + Status = status, + DurationMs = row.duration_ms + }; + } + + private static InspectionMetricResult MapMetric(InspectionMetricRow row) + { + return new InspectionMetricResult + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + MetricKey = row.metric_key, + MetricName = row.metric_name, + MetricValue = row.metric_value, + Unit = row.unit, + LowerLimit = ParseNullableDouble(row.lower_limit), + UpperLimit = ParseNullableDouble(row.upper_limit), + IsPass = row.is_pass != 0, + DisplayOrder = row.display_order + }; + } + + private static double? ParseNullableDouble(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return double.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static void ThrowIfQueryFailed(XP.Common.Database.Interfaces.IDbExecuteResult result, string message) + { + if (!result.IsSuccess) + { + throw new InvalidOperationException(message, result.Exception); + } + } + + private static InspectionAssetRecord MapAsset(InspectionAssetRow row) + { + _ = Enum.TryParse(row.asset_type, out var assetType); + return new InspectionAssetRecord + { + RunId = Guid.Parse(row.run_id), + NodeId = string.IsNullOrWhiteSpace(row.node_id) ? null : Guid.Parse(row.node_id), + AssetType = assetType, + RelativePath = row.relative_path, + FileFormat = row.file_format, + Width = row.width, + Height = row.height + }; + } + + private static PipelineExecutionSnapshot MapSnapshot(PipelineSnapshotRow row) + { + return new PipelineExecutionSnapshot + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + PipelineName = row.pipeline_name, + PipelineDefinitionJson = row.pipeline_definition_json, + PipelineHash = row.pipeline_hash + }; + } + + internal class InspectionRunRow + { + public string run_id { get; set; } = string.Empty; + public string program_name { get; set; } = string.Empty; + public string workpiece_id { get; set; } = string.Empty; + public string serial_number { get; set; } = string.Empty; + public string started_at { get; set; } = string.Empty; + public string completed_at { get; set; } = string.Empty; + public int overall_pass { get; set; } + public string source_image_path { get; set; } = string.Empty; + public string result_root_path { get; set; } = string.Empty; + public int node_count { get; set; } + } + + internal class InspectionNodeRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public int node_index { get; set; } + public string node_name { get; set; } = string.Empty; + public string pipeline_id { get; set; } = string.Empty; + public string pipeline_name { get; set; } = string.Empty; + public string pipeline_version_hash { get; set; } = string.Empty; + public int node_pass { get; set; } + public string source_image_path { get; set; } = string.Empty; + public string result_image_path { get; set; } = string.Empty; + public string status { get; set; } = string.Empty; + public long duration_ms { get; set; } + } + + internal class InspectionMetricRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string metric_key { get; set; } = string.Empty; + public string metric_name { get; set; } = string.Empty; + public double metric_value { get; set; } + public string unit { get; set; } = string.Empty; + public string lower_limit { get; set; } = string.Empty; + public string upper_limit { get; set; } = string.Empty; + public int is_pass { get; set; } + public int display_order { get; set; } + } + + internal class InspectionAssetRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string asset_type { get; set; } = string.Empty; + public string relative_path { get; set; } = string.Empty; + public string file_format { get; set; } = string.Empty; + public int width { get; set; } + public int height { get; set; } + } + + internal class PipelineSnapshotRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string pipeline_name { get; set; } = string.Empty; + public string pipeline_definition_json { get; set; } = string.Empty; + public string pipeline_hash { get; set; } = string.Empty; + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs index 325c9c2..61a3cdb 100644 --- a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs @@ -3,6 +3,7 @@ using Prism.Commands; using Prism.Events; using Prism.Mvvm; using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; @@ -18,27 +19,22 @@ using XplorePlane.Services.Cnc; namespace XplorePlane.ViewModels.Cnc { /// - /// CNC 编辑器 ViewModel,管理 CNC 程序的节点列表、编辑操作和文件操作 - /// CNC editor ViewModel that manages the node list, editing operations and file operations + /// CNC editor ViewModel that manages the node tree, editing operations and file operations. /// public class CncEditorViewModel : BindableBase { private readonly ICncProgramService _cncProgramService; - private readonly IAppStateService _appStateService; private readonly IEventAggregator _eventAggregator; private readonly ILoggerService _logger; - // 当前 CNC 程序 | Current CNC program private CncProgram _currentProgram; - private ObservableCollection _nodes; + private ObservableCollection _treeNodes; private CncNodeViewModel _selectedNode; private bool _isModified; private string _programName; + private Guid? _preferredSelectedNodeId; - /// - /// 构造函数 | Constructor - /// public CncEditorViewModel( ICncProgramService cncProgramService, IAppStateService appStateService, @@ -46,13 +42,13 @@ namespace XplorePlane.ViewModels.Cnc ILoggerService logger) { _cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService)); - _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); + ArgumentNullException.ThrowIfNull(appStateService); _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); _nodes = new ObservableCollection(); + _treeNodes = new ObservableCollection(); - // ── 节点插入命令 | Node insertion commands ── InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint)); InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage)); InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode)); @@ -63,138 +59,97 @@ namespace XplorePlane.ViewModels.Cnc InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay)); InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram)); - // ── 节点编辑命令 | Node editing commands ── DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode) .ObservesProperty(() => SelectedNode); MoveNodeUpCommand = new DelegateCommand(ExecuteMoveNodeUp); MoveNodeDownCommand = new DelegateCommand(ExecuteMoveNodeDown); - // ── 文件操作命令 | File operation commands ── SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync()); LoadProgramCommand = new DelegateCommand(async () => await ExecuteLoadProgramAsync()); NewProgramCommand = new DelegateCommand(ExecuteNewProgram); ExportCsvCommand = new DelegateCommand(ExecuteExportCsv); - _logger.Info("CncEditorViewModel 已初始化 | CncEditorViewModel initialized"); + _logger.Info("CncEditorViewModel initialized"); } - // ── 属性 | Properties ────────────────────────────────────────── - - /// 节点列表 | Node list public ObservableCollection Nodes { get => _nodes; - set => SetProperty(ref _nodes, value); + private set => SetProperty(ref _nodes, value); + } + + public ObservableCollection TreeNodes + { + get => _treeNodes; + private set => SetProperty(ref _treeNodes, value); } - /// 当前选中的节点 | Currently selected node public CncNodeViewModel SelectedNode { get => _selectedNode; - set => SetProperty(ref _selectedNode, value); + set + { + if (SetProperty(ref _selectedNode, value)) + { + RaisePropertyChanged(nameof(HasSelection)); + } + } } - /// 程序是否已修改 | Whether the program has been modified + public bool HasSelection => SelectedNode != null; + public bool IsModified { get => _isModified; set => SetProperty(ref _isModified, value); } - /// 当前程序名称 | Current program name public string ProgramName { get => _programName; set => SetProperty(ref _programName, value); } - // ── 节点插入命令 | Node insertion commands ────────────────────── - - /// 插入参考点命令 | Insert reference point command public DelegateCommand InsertReferencePointCommand { get; } - - /// 插入保存节点(含图像)命令 | Insert save node with image command public DelegateCommand InsertSaveNodeWithImageCommand { get; } - - /// 插入保存节点命令 | Insert save node command public DelegateCommand InsertSaveNodeCommand { get; } - - /// 插入保存位置命令 | Insert save position command public DelegateCommand InsertSavePositionCommand { get; } - - /// 插入检测模块命令 | Insert inspection module command public DelegateCommand InsertInspectionModuleCommand { get; } - - /// 插入检测标记命令 | Insert inspection marker command public DelegateCommand InsertInspectionMarkerCommand { get; } - - /// 插入停顿对话框命令 | Insert pause dialog command public DelegateCommand InsertPauseDialogCommand { get; } - - /// 插入等待延时命令 | Insert wait delay command public DelegateCommand InsertWaitDelayCommand { get; } - - /// 插入完成程序命令 | Insert complete program command public DelegateCommand InsertCompleteProgramCommand { get; } - - // ── 节点编辑命令 | Node editing commands ──────────────────────── - - /// 删除选中节点命令 | Delete selected node command public DelegateCommand DeleteNodeCommand { get; } - - /// 上移节点命令 | Move node up command public DelegateCommand MoveNodeUpCommand { get; } - - /// 下移节点命令 | Move node down command public DelegateCommand MoveNodeDownCommand { get; } - - // ── 文件操作命令 | File operation commands ────────────────────── - - /// 保存程序命令 | Save program command public DelegateCommand SaveProgramCommand { get; } - - /// 加载程序命令 | Load program command public DelegateCommand LoadProgramCommand { get; } - - /// 新建程序命令 | New program command public DelegateCommand NewProgramCommand { get; } - - /// 导出 CSV 命令 | Export CSV command public DelegateCommand ExportCsvCommand { get; } - // ── 命令执行方法 | Command execution methods ──────────────────── - - /// - /// 插入指定类型的节点到选中节点之后 - /// Insert a node of the specified type after the selected node - /// private void ExecuteInsertNode(CncNodeType nodeType) { if (_currentProgram == null) { - _logger.Warn("无法插入节点:当前无程序 | Cannot insert node: no current program"); - return; + ExecuteNewProgram(); } try { var node = _cncProgramService.CreateNode(nodeType); - int afterIndex = SelectedNode?.Index ?? _currentProgram.Nodes.Count - 1; + int afterIndex = ResolveInsertAfterIndex(nodeType); _currentProgram = _cncProgramService.InsertNode(_currentProgram, afterIndex, node); + _preferredSelectedNodeId = node.Id; OnProgramEdited(); - _logger.Info("已插入节点 | Inserted node: Type={NodeType}", nodeType); + _logger.Info("Inserted node: Type={NodeType}", nodeType); } catch (InvalidOperationException ex) { - // 重复插入 CompleteProgram 等业务规则异常 | Business rule exceptions like duplicate CompleteProgram - _logger.Warn("插入节点被阻止 | Node insertion blocked: {Message}", ex.Message); + _logger.Warn("Node insertion blocked: {Message}", ex.Message); } } - /// - /// 删除选中节点 | Delete the selected node - /// private void ExecuteDeleteNode() { if (_currentProgram == null || SelectedNode == null) @@ -202,20 +157,28 @@ namespace XplorePlane.ViewModels.Cnc try { - _currentProgram = _cncProgramService.RemoveNode(_currentProgram, SelectedNode.Index); + if (SelectedNode.IsSavePosition) + { + var nodes = _currentProgram.Nodes.ToList(); + int startIndex = SelectedNode.Index; + int endIndex = GetSavePositionBlockEndIndex(startIndex); + nodes.RemoveRange(startIndex, endIndex - startIndex + 1); + _currentProgram = ReplaceProgramNodes(nodes); + } + else + { + _currentProgram = _cncProgramService.RemoveNode(_currentProgram, SelectedNode.Index); + } + OnProgramEdited(); - _logger.Info("已删除节点 | Deleted node at index: {Index}", SelectedNode.Index); + _logger.Info("Deleted node at index: {Index}", SelectedNode.Index); } catch (ArgumentOutOfRangeException ex) { - _logger.Warn("删除节点失败 | Delete node failed: {Message}", ex.Message); + _logger.Warn("Delete node failed: {Message}", ex.Message); } } - /// - /// 判断是否可以删除节点(至少保留 1 个节点) - /// Determines whether delete is allowed (at least 1 node must remain) - /// private bool CanExecuteDeleteNode() { return SelectedNode != null @@ -223,9 +186,6 @@ namespace XplorePlane.ViewModels.Cnc && _currentProgram.Nodes.Count > 1; } - /// - /// 上移节点 | Move node up - /// private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm) { if (_currentProgram == null || nodeVm == null || nodeVm.Index <= 0) @@ -233,19 +193,23 @@ namespace XplorePlane.ViewModels.Cnc try { - _currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, nodeVm.Index - 1); + if (IsSavePositionChild(nodeVm.NodeType)) + { + MoveSavePositionChild(nodeVm, moveDown: false); + } + else + { + MoveRootBlock(nodeVm, moveDown: false); + } + OnProgramEdited(); - _logger.Info("已上移节点 | Moved node up: {OldIndex} -> {NewIndex}", nodeVm.Index, nodeVm.Index - 1); } - catch (ArgumentOutOfRangeException ex) + catch (Exception ex) when (ex is ArgumentOutOfRangeException or InvalidOperationException) { - _logger.Warn("上移节点失败 | Move node up failed: {Message}", ex.Message); + _logger.Warn("Move node up failed: {Message}", ex.Message); } } - /// - /// 下移节点 | Move node down - /// private void ExecuteMoveNodeDown(CncNodeViewModel nodeVm) { if (_currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1) @@ -253,24 +217,28 @@ namespace XplorePlane.ViewModels.Cnc try { - _currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, nodeVm.Index + 1); + if (IsSavePositionChild(nodeVm.NodeType)) + { + MoveSavePositionChild(nodeVm, moveDown: true); + } + else + { + MoveRootBlock(nodeVm, moveDown: true); + } + OnProgramEdited(); - _logger.Info("已下移节点 | Moved node down: {OldIndex} -> {NewIndex}", nodeVm.Index, nodeVm.Index + 1); } - catch (ArgumentOutOfRangeException ex) + catch (Exception ex) when (ex is ArgumentOutOfRangeException or InvalidOperationException) { - _logger.Warn("下移节点失败 | Move node down failed: {Message}", ex.Message); + _logger.Warn("Move node down failed: {Message}", ex.Message); } } - /// - /// 保存当前程序到文件 | Save current program to file - /// private async Task ExecuteSaveProgramAsync() { if (_currentProgram == null) { - _logger.Warn("无法保存:当前无程序 | Cannot save: no current program"); + _logger.Warn("Cannot save: no current program"); return; } @@ -289,17 +257,13 @@ namespace XplorePlane.ViewModels.Cnc await _cncProgramService.SaveAsync(_currentProgram, dlg.FileName); IsModified = false; - _logger.Info("程序已保存 | Program saved: {FilePath}", dlg.FileName); } catch (Exception ex) { - _logger.Error(ex, "保存程序失败 | Failed to save program"); + _logger.Error(ex, "Failed to save program"); } } - /// - /// 从文件加载程序 | Load program from file - /// private async Task ExecuteLoadProgramAsync() { try @@ -318,35 +282,27 @@ namespace XplorePlane.ViewModels.Cnc ProgramName = _currentProgram.Name; IsModified = false; RefreshNodes(); - _logger.Info("程序已加载 | Program loaded: {ProgramName}", _currentProgram.Name); } catch (Exception ex) { - _logger.Error(ex, "加载程序失败 | Failed to load program"); + _logger.Error(ex, "Failed to load program"); } } - /// - /// 创建新程序 | Create a new program - /// private void ExecuteNewProgram() { - var name = string.IsNullOrWhiteSpace(ProgramName) ? "新程序" : ProgramName; + var name = string.IsNullOrWhiteSpace(ProgramName) ? "NewCncProgram" : ProgramName; _currentProgram = _cncProgramService.CreateProgram(name); ProgramName = _currentProgram.Name; IsModified = false; RefreshNodes(); - _logger.Info("已创建新程序 | Created new program: {ProgramName}", name); } - /// - /// 导出当前程序为 CSV 文件 | Export current program to CSV file - /// private void ExecuteExportCsv() { if (_currentProgram == null || _currentProgram.Nodes.Count == 0) { - _logger.Warn("无法导出 CSV:当前无程序或节点为空 | Cannot export CSV: no program or empty nodes"); + _logger.Warn("Cannot export CSV: no program or empty nodes"); return; } @@ -364,8 +320,7 @@ namespace XplorePlane.ViewModels.Cnc return; var sb = new StringBuilder(); - // CSV 表头 | CSV header - sb.AppendLine("Index,NodeType,Name,XM,YM,ZT,ZD,TiltD,Dist,Voltage_kV,Power_W,RayOn,DetectorConnected,FrameRate,Resolution,ImageFile,MarkerType,MarkerX,MarkerY,DialogTitle,DialogMessage,DelayMs,Pipeline"); + sb.AppendLine("Index,NodeType,Name,XM,YM,ZT,ZD,TiltD,Dist,Voltage_kV,Current_uA,Power_W,RayOn,DetectorConnected,FrameRate,Resolution,ImageFile,MarkerType,MarkerX,MarkerY,DialogTitle,DialogMessage,DelayMs,Pipeline"); var inv = CultureInfo.InvariantCulture; @@ -373,24 +328,15 @@ namespace XplorePlane.ViewModels.Cnc { var row = node switch { - ReferencePointNode rp => $"{rp.Index},{rp.NodeType},{Esc(rp.Name)},{rp.XM.ToString(inv)},{rp.YM.ToString(inv)},{rp.ZT.ToString(inv)},{rp.ZD.ToString(inv)},{rp.TiltD.ToString(inv)},{rp.Dist.ToString(inv)},,,,,,,,,,,,,,", - - SaveNodeWithImageNode sni => $"{sni.Index},{sni.NodeType},{Esc(sni.Name)},{sni.MotionState.XM.ToString(inv)},{sni.MotionState.YM.ToString(inv)},{sni.MotionState.ZT.ToString(inv)},{sni.MotionState.ZD.ToString(inv)},{sni.MotionState.TiltD.ToString(inv)},{sni.MotionState.Dist.ToString(inv)},{sni.RaySourceState.Voltage.ToString(inv)},{sni.RaySourceState.Power.ToString(inv)},{sni.RaySourceState.IsOn},{sni.DetectorState.IsConnected},{sni.DetectorState.FrameRate.ToString(inv)},{Esc(sni.DetectorState.Resolution)},{Esc(sni.ImageFileName)},,,,,,", - - SaveNodeNode sn => $"{sn.Index},{sn.NodeType},{Esc(sn.Name)},{sn.MotionState.XM.ToString(inv)},{sn.MotionState.YM.ToString(inv)},{sn.MotionState.ZT.ToString(inv)},{sn.MotionState.ZD.ToString(inv)},{sn.MotionState.TiltD.ToString(inv)},{sn.MotionState.Dist.ToString(inv)},{sn.RaySourceState.Voltage.ToString(inv)},{sn.RaySourceState.Power.ToString(inv)},{sn.RaySourceState.IsOn},{sn.DetectorState.IsConnected},{sn.DetectorState.FrameRate.ToString(inv)},{Esc(sn.DetectorState.Resolution)},,,,,,,", - + ReferencePointNode rp => $"{rp.Index},{rp.NodeType},{Esc(rp.Name)},{rp.XM.ToString(inv)},{rp.YM.ToString(inv)},{rp.ZT.ToString(inv)},{rp.ZD.ToString(inv)},{rp.TiltD.ToString(inv)},{rp.Dist.ToString(inv)},{rp.Voltage.ToString(inv)},{rp.Current.ToString(inv)},,{rp.IsRayOn},,,,,,,,,,", + SaveNodeWithImageNode sni => $"{sni.Index},{sni.NodeType},{Esc(sni.Name)},{sni.MotionState.XM.ToString(inv)},{sni.MotionState.YM.ToString(inv)},{sni.MotionState.ZT.ToString(inv)},{sni.MotionState.ZD.ToString(inv)},{sni.MotionState.TiltD.ToString(inv)},{sni.MotionState.Dist.ToString(inv)},{sni.RaySourceState.Voltage.ToString(inv)},,{sni.RaySourceState.Power.ToString(inv)},{sni.RaySourceState.IsOn},{sni.DetectorState.IsConnected},{sni.DetectorState.FrameRate.ToString(inv)},{Esc(sni.DetectorState.Resolution)},{Esc(sni.ImageFileName)},,,,,,", + SaveNodeNode sn => $"{sn.Index},{sn.NodeType},{Esc(sn.Name)},{sn.MotionState.XM.ToString(inv)},{sn.MotionState.YM.ToString(inv)},{sn.MotionState.ZT.ToString(inv)},{sn.MotionState.ZD.ToString(inv)},{sn.MotionState.TiltD.ToString(inv)},{sn.MotionState.Dist.ToString(inv)},{sn.RaySourceState.Voltage.ToString(inv)},,{sn.RaySourceState.Power.ToString(inv)},{sn.RaySourceState.IsOn},{sn.DetectorState.IsConnected},{sn.DetectorState.FrameRate.ToString(inv)},{Esc(sn.DetectorState.Resolution)},,,,,,,", SavePositionNode sp => $"{sp.Index},{sp.NodeType},{Esc(sp.Name)},{sp.MotionState.XM.ToString(inv)},{sp.MotionState.YM.ToString(inv)},{sp.MotionState.ZT.ToString(inv)},{sp.MotionState.ZD.ToString(inv)},{sp.MotionState.TiltD.ToString(inv)},{sp.MotionState.Dist.ToString(inv)},,,,,,,,,,,,,,", - InspectionModuleNode im => $"{im.Index},{im.NodeType},{Esc(im.Name)},,,,,,,,,,,,,,,,,,,{Esc(im.Pipeline?.Name ?? string.Empty)}", - InspectionMarkerNode mk => $"{mk.Index},{mk.NodeType},{Esc(mk.Name)},,,,,,,,,,,,,,{Esc(mk.MarkerType)},{mk.MarkerX.ToString(inv)},{mk.MarkerY.ToString(inv)},,,", - PauseDialogNode pd => $"{pd.Index},{pd.NodeType},{Esc(pd.Name)},,,,,,,,,,,,,,,,,{Esc(pd.DialogTitle)},{Esc(pd.DialogMessage)},,", - WaitDelayNode wd => $"{wd.Index},{wd.NodeType},{Esc(wd.Name)},,,,,,,,,,,,,,,,,,,{wd.DelayMilliseconds},", - CompleteProgramNode cp => $"{cp.Index},{cp.NodeType},{Esc(cp.Name)},,,,,,,,,,,,,,,,,,,,", - _ => $"{node.Index},{node.NodeType},{Esc(node.Name)},,,,,,,,,,,,,,,,,,,," }; @@ -398,18 +344,13 @@ namespace XplorePlane.ViewModels.Cnc } File.WriteAllText(dlg.FileName, sb.ToString(), Encoding.UTF8); - _logger.Info("CSV 已导出 | CSV exported: {FilePath}, 节点数={Count}", dlg.FileName, _currentProgram.Nodes.Count); } catch (Exception ex) { - _logger.Error(ex, "导出 CSV 失败 | Failed to export CSV"); + _logger.Error(ex, "Failed to export CSV"); } } - /// - /// CSV 字段转义:含逗号、引号或换行时用双引号包裹 - /// Escape CSV field: wrap with double quotes if it contains comma, quote, or newline - /// private static string Esc(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; @@ -418,12 +359,6 @@ namespace XplorePlane.ViewModels.Cnc return value; } - // ── 辅助方法 | Helper methods ─────────────────────────────────── - - /// - /// 编辑操作后的统一处理:刷新节点、标记已修改、发布变更事件 - /// Unified post-edit handling: refresh nodes, mark modified, publish change event - /// private void OnProgramEdited() { IsModified = true; @@ -431,34 +366,256 @@ namespace XplorePlane.ViewModels.Cnc PublishProgramChanged(); } - /// - /// 从 _currentProgram.Nodes 重建 Nodes 集合 - /// Rebuild the Nodes collection from _currentProgram.Nodes - /// - private void RefreshNodes() + private void HandleNodeModelChanged(CncNodeViewModel nodeVm, CncNode updatedNode) { - Nodes.Clear(); - - if (_currentProgram?.Nodes == null) + if (_currentProgram == null) return; - foreach (var node in _currentProgram.Nodes) - { - Nodes.Add(new CncNodeViewModel(node)); - } - - // 尝试保持选中状态 | Try to preserve selection - if (SelectedNode != null) - { - var match = Nodes.FirstOrDefault(n => n.Index == SelectedNode.Index); - SelectedNode = match ?? Nodes.LastOrDefault(); - } + _currentProgram = _cncProgramService.UpdateNode(_currentProgram, nodeVm.Index, updatedNode); + IsModified = true; + ProgramName = _currentProgram.Name; + PublishProgramChanged(); + } + + private void RefreshNodes() + { + var selectedId = _preferredSelectedNodeId ?? SelectedNode?.Id; + var expansionState = Nodes.ToDictionary(node => node.Id, node => node.IsExpanded); + + var flatNodes = new List(); + var rootNodes = new List(); + CncNodeViewModel currentSavePosition = null; + + if (_currentProgram?.Nodes != null) + { + foreach (var node in _currentProgram.Nodes) + { + var vm = new CncNodeViewModel(node, HandleNodeModelChanged) + { + IsExpanded = expansionState.TryGetValue(node.Id, out var isExpanded) ? isExpanded : true + }; + + flatNodes.Add(vm); + + if (vm.IsSavePosition) + { + rootNodes.Add(vm); + currentSavePosition = vm; + continue; + } + + if (currentSavePosition != null && IsSavePositionChild(vm.NodeType)) + { + currentSavePosition.Children.Add(vm); + continue; + } + + rootNodes.Add(vm); + currentSavePosition = null; + } + } + + Nodes = new ObservableCollection(flatNodes); + TreeNodes = new ObservableCollection(rootNodes); + + SelectedNode = selectedId.HasValue + ? Nodes.FirstOrDefault(node => node.Id == selectedId.Value) ?? Nodes.LastOrDefault() + : Nodes.LastOrDefault(); + + _preferredSelectedNodeId = null; + } + + private int ResolveInsertAfterIndex(CncNodeType nodeType) + { + if (_currentProgram == null || _currentProgram.Nodes.Count == 0) + { + return -1; + } + + if (!IsSavePositionChild(nodeType)) + { + return SelectedNode?.Index ?? _currentProgram.Nodes.Count - 1; + } + + int? savePositionIndex = FindOwningSavePositionIndex(SelectedNode?.Index); + if (!savePositionIndex.HasValue) + { + throw new InvalidOperationException("请先选择一个“保存位置”节点,再插入标记点或检测模块。"); + } + + return GetSavePositionBlockEndIndex(savePositionIndex.Value); + } + + private void MoveSavePositionChild(CncNodeViewModel nodeVm, bool moveDown) + { + int? parentIndex = FindOwningSavePositionIndex(nodeVm.Index); + if (!parentIndex.HasValue) + { + throw new InvalidOperationException("当前子节点未归属于任何保存位置,无法移动。"); + } + + int childStartIndex = parentIndex.Value + 1; + int childEndIndex = GetSavePositionBlockEndIndex(parentIndex.Value); + int targetIndex = moveDown ? nodeVm.Index + 1 : nodeVm.Index - 1; + + if (targetIndex < childStartIndex || targetIndex > childEndIndex) + { + return; + } + + _currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, targetIndex); + _preferredSelectedNodeId = nodeVm.Id; + } + + private void MoveRootBlock(CncNodeViewModel nodeVm, bool moveDown) + { + var blocks = BuildRootBlocks(_currentProgram.Nodes); + int blockIndex = blocks.FindIndex(block => block.Start == nodeVm.Index); + if (blockIndex < 0) + { + return; + } + + if (!moveDown && blockIndex == 0) + { + return; + } + + if (moveDown && blockIndex >= blocks.Count - 1) + { + return; + } + + var currentBlock = blocks[blockIndex]; + var targetBlock = blocks[moveDown ? blockIndex + 1 : blockIndex - 1]; + var nodes = _currentProgram.Nodes.ToList(); + var movingNodes = nodes.GetRange(currentBlock.Start, currentBlock.End - currentBlock.Start + 1); + + nodes.RemoveRange(currentBlock.Start, movingNodes.Count); + + int insertAt = moveDown + ? targetBlock.End - movingNodes.Count + 1 + : targetBlock.Start; + + nodes.InsertRange(insertAt, movingNodes); + + _currentProgram = ReplaceProgramNodes(nodes); + _preferredSelectedNodeId = nodeVm.Id; + } + + private int? FindOwningSavePositionIndex(int? startIndex) + { + if (!startIndex.HasValue || _currentProgram == null) + { + return null; + } + + int index = startIndex.Value; + if (index < 0 || index >= _currentProgram.Nodes.Count) + { + return null; + } + + var selectedType = _currentProgram.Nodes[index].NodeType; + if (_currentProgram.Nodes[index].NodeType == CncNodeType.SavePosition) + { + return index; + } + + if (!IsSavePositionChild(selectedType)) + { + return null; + } + + for (int i = index - 1; i >= 0; i--) + { + var type = _currentProgram.Nodes[i].NodeType; + if (type == CncNodeType.SavePosition) + { + return i; + } + + if (!IsSavePositionChild(type)) + { + break; + } + } + + return null; + } + + private int GetSavePositionBlockEndIndex(int savePositionIndex) + { + if (_currentProgram == null) + { + return savePositionIndex; + } + + int endIndex = savePositionIndex; + for (int i = savePositionIndex + 1; i < _currentProgram.Nodes.Count; i++) + { + if (!IsSavePositionChild(_currentProgram.Nodes[i].NodeType)) + { + break; + } + + endIndex = i; + } + + return endIndex; + } + + private static List<(int Start, int End)> BuildRootBlocks(IReadOnlyList nodes) + { + var blocks = new List<(int Start, int End)>(); + + for (int i = 0; i < nodes.Count; i++) + { + if (IsSavePositionChild(nodes[i].NodeType)) + { + continue; + } + + int end = i; + if (nodes[i].NodeType == CncNodeType.SavePosition) + { + for (int j = i + 1; j < nodes.Count; j++) + { + if (!IsSavePositionChild(nodes[j].NodeType)) + { + break; + } + + end = j; + } + } + + blocks.Add((i, end)); + } + + return blocks; + } + + private CncProgram ReplaceProgramNodes(List nodes) + { + var renumberedNodes = nodes + .Select((node, index) => node with { Index = index }) + .ToList() + .AsReadOnly(); + + return _currentProgram with + { + Nodes = renumberedNodes, + UpdatedAt = DateTime.UtcNow + }; + } + + private static bool IsSavePositionChild(CncNodeType type) + { + return type is CncNodeType.InspectionMarker + or CncNodeType.InspectionModule; } - /// - /// 通过 IEventAggregator 发布 CNC 程序变更事件 - /// Publish CNC program changed event via IEventAggregator - /// private void PublishProgramChanged() { _eventAggregator @@ -466,4 +623,4 @@ namespace XplorePlane.ViewModels.Cnc .Publish(new CncProgramChangedPayload(ProgramName ?? string.Empty, IsModified)); } } -} \ No newline at end of file +} diff --git a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs new file mode 100644 index 0000000..b6dc7d7 --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs @@ -0,0 +1,401 @@ +using Microsoft.Win32; +using Prism.Commands; +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Windows; +using System.Windows.Input; +using XplorePlane.Models; +using XplorePlane.Services; +using XP.Common.Logging.Interfaces; + +namespace XplorePlane.ViewModels.Cnc +{ + public class CncInspectionModulePipelineViewModel : BindableBase, IPipelineEditorHostViewModel + { + private readonly CncEditorViewModel _editorViewModel; + private readonly IImageProcessingService _imageProcessingService; + private readonly IPipelinePersistenceService _persistenceService; + private readonly ILoggerService _logger; + + private CncNodeViewModel _activeModuleNode; + private PipelineNodeViewModel _selectedNode; + private string _statusMessage = "请选择检测模块以编辑其流水线。"; + private string _currentFilePath; + private bool _isSynchronizing; + + public CncInspectionModulePipelineViewModel( + CncEditorViewModel editorViewModel, + IImageProcessingService imageProcessingService, + IPipelinePersistenceService persistenceService, + ILoggerService logger) + { + _editorViewModel = editorViewModel ?? throw new ArgumentNullException(nameof(editorViewModel)); + _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); + _persistenceService = persistenceService ?? throw new ArgumentNullException(nameof(persistenceService)); + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); + + PipelineNodes = new ObservableCollection(); + + AddOperatorCommand = new DelegateCommand(AddOperator, _ => HasActiveModule); + RemoveOperatorCommand = new DelegateCommand(RemoveOperator); + MoveNodeUpCommand = new DelegateCommand(MoveNodeUp); + MoveNodeDownCommand = new DelegateCommand(MoveNodeDown); + NewPipelineCommand = new DelegateCommand(NewPipeline); + SavePipelineCommand = new DelegateCommand(SavePipelineToModule); + SaveAsPipelineCommand = new DelegateCommand(async () => await SaveAsPipelineAsync()); + LoadPipelineCommand = new DelegateCommand(async () => await LoadPipelineAsync()); + + _editorViewModel.PropertyChanged += OnEditorPropertyChanged; + RefreshFromSelection(); + } + + public ObservableCollection PipelineNodes { get; } + + public PipelineNodeViewModel SelectedNode + { + get => _selectedNode; + set => SetProperty(ref _selectedNode, value); + } + + public string StatusMessage + { + get => _statusMessage; + private set => SetProperty(ref _statusMessage, value); + } + + public bool HasActiveModule => _activeModuleNode?.IsInspectionModule == true; + + public Visibility EditorVisibility => HasActiveModule ? Visibility.Visible : Visibility.Collapsed; + + public Visibility EmptyStateVisibility => HasActiveModule ? Visibility.Collapsed : Visibility.Visible; + + public ICommand AddOperatorCommand { get; } + + public ICommand RemoveOperatorCommand { get; } + + public ICommand MoveNodeUpCommand { get; } + + public ICommand MoveNodeDownCommand { get; } + + public ICommand NewPipelineCommand { get; } + + public ICommand SavePipelineCommand { get; } + + public ICommand SaveAsPipelineCommand { get; } + + public ICommand LoadPipelineCommand { get; } + + private void OnEditorPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CncEditorViewModel.SelectedNode)) + { + RefreshFromSelection(); + } + } + + private void RefreshFromSelection() + { + var selected = _editorViewModel.SelectedNode; + if (selected == null || !selected.IsInspectionModule) + { + _activeModuleNode = null; + PipelineNodes.Clear(); + SelectedNode = null; + StatusMessage = "请选择检测模块以编辑其流水线。"; + RaiseModuleVisibilityChanged(); + RaiseCommandCanExecuteChanged(); + return; + } + + _activeModuleNode = selected; + LoadPipelineModel(_activeModuleNode.Pipeline ?? new PipelineModel + { + Name = _activeModuleNode.Name + }); + RaiseModuleVisibilityChanged(); + RaiseCommandCanExecuteChanged(); + } + + private void AddOperator(string operatorKey) + { + if (!HasActiveModule || string.IsNullOrWhiteSpace(operatorKey)) + return; + + var available = _imageProcessingService.GetAvailableProcessors(); + if (!available.Contains(operatorKey)) + { + StatusMessage = $"算子 '{operatorKey}' 未注册。"; + return; + } + + var displayName = _imageProcessingService.GetProcessorDisplayName(operatorKey) ?? operatorKey; + var icon = ProcessorUiMetadata.GetOperatorIcon(operatorKey); + var node = new PipelineNodeViewModel(operatorKey, displayName, icon) + { + Order = PipelineNodes.Count + }; + + LoadNodeParameters(node, null); + PipelineNodes.Add(node); + SelectedNode = node; + PersistActiveModule($"已添加算子:{displayName}"); + } + + private void RemoveOperator(PipelineNodeViewModel node) + { + if (!HasActiveModule || node == null || !PipelineNodes.Contains(node)) + return; + + PipelineNodes.Remove(node); + RenumberNodes(); + + if (SelectedNode == node) + { + SelectedNode = PipelineNodes.LastOrDefault(); + } + + PersistActiveModule($"已移除算子:{node.DisplayName}"); + } + + private void MoveNodeUp(PipelineNodeViewModel node) + { + if (!HasActiveModule || node == null) + return; + + var index = PipelineNodes.IndexOf(node); + if (index <= 0) + return; + + PipelineNodes.Move(index, index - 1); + RenumberNodes(); + PersistActiveModule($"已上移算子:{node.DisplayName}"); + } + + private void MoveNodeDown(PipelineNodeViewModel node) + { + if (!HasActiveModule || node == null) + return; + + var index = PipelineNodes.IndexOf(node); + if (index < 0 || index >= PipelineNodes.Count - 1) + return; + + PipelineNodes.Move(index, index + 1); + RenumberNodes(); + PersistActiveModule($"已下移算子:{node.DisplayName}"); + } + + private void NewPipeline() + { + if (!HasActiveModule) + return; + + PipelineNodes.Clear(); + SelectedNode = null; + _currentFilePath = null; + PersistActiveModule("已为当前检测模块新建空流水线。"); + } + + private void SavePipelineToModule() + { + if (!HasActiveModule) + return; + + PersistActiveModule("当前检测模块流水线已同步到 CNC 程序。"); + } + + private async System.Threading.Tasks.Task SaveAsPipelineAsync() + { + if (!HasActiveModule) + return; + + var dialog = new SaveFileDialog + { + Filter = "图像处理流水线 (*.imw)|*.imw", + FileName = GetActivePipelineName() + }; + + if (dialog.ShowDialog() != true) + return; + + var model = BuildPipelineModel(); + await _persistenceService.SaveAsync(model, dialog.FileName); + _currentFilePath = dialog.FileName; + StatusMessage = $"已导出模块流水线:{Path.GetFileName(dialog.FileName)}"; + } + + private async System.Threading.Tasks.Task LoadPipelineAsync() + { + if (!HasActiveModule) + return; + + var dialog = new OpenFileDialog + { + Filter = "图像处理流水线 (*.imw)|*.imw" + }; + + if (dialog.ShowDialog() != true) + return; + + var model = await _persistenceService.LoadAsync(dialog.FileName); + _currentFilePath = dialog.FileName; + LoadPipelineModel(model); + PersistActiveModule($"已加载模块流水线:{model.Name}"); + } + + private void LoadPipelineModel(PipelineModel pipeline) + { + _isSynchronizing = true; + try + { + PipelineNodes.Clear(); + SelectedNode = null; + + var orderedNodes = (pipeline?.Nodes ?? new List()) + .OrderBy(node => node.Order) + .ToList(); + + foreach (var nodeModel in orderedNodes) + { + var displayName = _imageProcessingService.GetProcessorDisplayName(nodeModel.OperatorKey) ?? nodeModel.OperatorKey; + var icon = ProcessorUiMetadata.GetOperatorIcon(nodeModel.OperatorKey); + var node = new PipelineNodeViewModel(nodeModel.OperatorKey, displayName, icon) + { + Order = nodeModel.Order, + IsEnabled = nodeModel.IsEnabled + }; + + LoadNodeParameters(node, nodeModel.Parameters); + PipelineNodes.Add(node); + } + + SelectedNode = PipelineNodes.FirstOrDefault(); + StatusMessage = HasActiveModule + ? $"正在编辑检测模块:{_activeModuleNode.Name}" + : "请选择检测模块以编辑其流水线。"; + } + finally + { + _isSynchronizing = false; + } + } + + private void LoadNodeParameters(PipelineNodeViewModel node, IDictionary savedValues) + { + var parameterDefinitions = _imageProcessingService.GetProcessorParameters(node.OperatorKey); + if (parameterDefinitions == null) + return; + + node.Parameters.Clear(); + foreach (var definition in parameterDefinitions) + { + var parameterVm = new ProcessorParameterVM(definition); + if (savedValues != null && savedValues.TryGetValue(definition.Name, out var savedValue)) + { + parameterVm.Value = ConvertSavedValue(savedValue, definition.ValueType); + } + + parameterVm.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ProcessorParameterVM.Value) && !_isSynchronizing) + { + PersistActiveModule($"已更新参数:{parameterVm.DisplayName}"); + } + }; + node.Parameters.Add(parameterVm); + } + } + + private void PersistActiveModule(string statusMessage) + { + if (!HasActiveModule || _isSynchronizing) + return; + + _activeModuleNode.Pipeline = BuildPipelineModel(); + StatusMessage = statusMessage; + } + + private PipelineModel BuildPipelineModel() + { + return new PipelineModel + { + Id = _activeModuleNode?.Pipeline?.Id ?? Guid.NewGuid(), + Name = GetActivePipelineName(), + DeviceId = _activeModuleNode?.Pipeline?.DeviceId ?? string.Empty, + CreatedAt = _activeModuleNode?.Pipeline?.CreatedAt ?? DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Nodes = PipelineNodes.Select((node, index) => new PipelineNodeModel + { + Id = node.Id, + OperatorKey = node.OperatorKey, + Order = index, + IsEnabled = node.IsEnabled, + Parameters = node.Parameters.ToDictionary(parameter => parameter.Name, parameter => parameter.Value) + }).ToList() + }; + } + + private string GetActivePipelineName() + { + if (!HasActiveModule) + return "InspectionModulePipeline"; + + return string.IsNullOrWhiteSpace(_activeModuleNode.Pipeline?.Name) + ? _activeModuleNode.Name + : _activeModuleNode.Pipeline.Name; + } + + private void RenumberNodes() + { + for (var i = 0; i < PipelineNodes.Count; i++) + { + PipelineNodes[i].Order = i; + } + } + + private void RaiseModuleVisibilityChanged() + { + RaisePropertyChanged(nameof(HasActiveModule)); + RaisePropertyChanged(nameof(EditorVisibility)); + RaisePropertyChanged(nameof(EmptyStateVisibility)); + } + + private void RaiseCommandCanExecuteChanged() + { + (AddOperatorCommand as DelegateCommand)?.RaiseCanExecuteChanged(); + } + + private static object ConvertSavedValue(object savedValue, Type targetType) + { + if (savedValue is not JsonElement jsonElement) + return savedValue; + + try + { + if (targetType == typeof(int)) + return jsonElement.GetInt32(); + + if (targetType == typeof(double)) + return jsonElement.GetDouble(); + + if (targetType == typeof(bool)) + return jsonElement.GetBoolean(); + + if (targetType == typeof(string)) + return jsonElement.GetString() ?? string.Empty; + + return jsonElement.ToString(); + } + catch + { + return jsonElement.ToString(); + } + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs index 4fa752b..6e00950 100644 --- a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs @@ -1,89 +1,553 @@ using Prism.Mvvm; +using System; +using System.Collections.ObjectModel; using XplorePlane.Models; namespace XplorePlane.ViewModels.Cnc { /// - /// CNC 节点 ViewModel,将 CncNode 模型封装为可绑定的 WPF ViewModel - /// CNC node ViewModel that wraps a CncNode model into a bindable WPF ViewModel + /// CNC node ViewModel with editable properties and tree children. /// public class CncNodeViewModel : BindableBase { - private int _index; - private string _name; - private CncNodeType _nodeType; + private readonly Action _modelChangedCallback; + private CncNode _model; private string _icon; + private bool _isExpanded = true; - /// - /// 构造函数,从 CncNode 模型初始化 ViewModel - /// Constructor that initializes the ViewModel from a CncNode model - /// - public CncNodeViewModel(CncNode model) + public CncNodeViewModel(CncNode model, Action modelChangedCallback) { - Model = model; - _index = model.Index; - _name = model.Name; - _nodeType = model.NodeType; + _model = model ?? throw new ArgumentNullException(nameof(model)); + _modelChangedCallback = modelChangedCallback ?? throw new ArgumentNullException(nameof(modelChangedCallback)); _icon = GetIconForNodeType(model.NodeType); + Children = new ObservableCollection(); } - /// 底层 CNC 节点模型(只读)| Underlying CNC node model (read-only) - public CncNode Model { get; } + public ObservableCollection Children { get; } - /// 节点在程序中的索引 | Node index in the program - public int Index - { - get => _index; - set => SetProperty(ref _index, value); - } + public CncNode Model => _model; + + public Guid Id => _model.Id; + + public int Index => _model.Index; - /// 节点显示名称 | Node display name public string Name { - get => _name; - set => SetProperty(ref _name, value); + get => _model.Name; + set => UpdateModel(_model with { Name = value ?? string.Empty }); } - /// 节点类型 | Node type - public CncNodeType NodeType + public CncNodeType NodeType => _model.NodeType; + + public string NodeTypeDisplay => NodeType.ToString(); + + public string Icon { - get => _nodeType; + get => _icon; + private set => SetProperty(ref _icon, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public bool HasChildren => Children.Count > 0; + + public bool IsReferencePoint => _model is ReferencePointNode; + public bool IsSaveNode => _model is SaveNodeNode; + public bool IsSaveNodeWithImage => _model is SaveNodeWithImageNode; + public bool IsSavePosition => _model is SavePositionNode; + public bool IsInspectionModule => _model is InspectionModuleNode; + public bool IsInspectionMarker => _model is InspectionMarkerNode; + public bool IsPauseDialog => _model is PauseDialogNode; + public bool IsWaitDelay => _model is WaitDelayNode; + public bool IsCompleteProgram => _model is CompleteProgramNode; + public bool IsPositionChild => _model is InspectionModuleNode or InspectionMarkerNode; + public bool IsMotionSnapshotNode => _model is ReferencePointNode or SaveNodeNode or SaveNodeWithImageNode or SavePositionNode; + public string RelationTag => _model switch + { + SavePositionNode => "位置", + InspectionModuleNode => "检测模块", + InspectionMarkerNode => "检测标记", + _ => string.Empty + }; + + public double XM + { + get => _model switch + { + ReferencePointNode rp => rp.XM, + SaveNodeNode sn => sn.MotionState.XM, + SaveNodeWithImageNode sni => sni.MotionState.XM, + SavePositionNode sp => sp.MotionState.XM, + _ => 0d + }; + set => UpdateMotion(value, MotionAxis.XM); + } + + public double YM + { + get => _model switch + { + ReferencePointNode rp => rp.YM, + SaveNodeNode sn => sn.MotionState.YM, + SaveNodeWithImageNode sni => sni.MotionState.YM, + SavePositionNode sp => sp.MotionState.YM, + _ => 0d + }; + set => UpdateMotion(value, MotionAxis.YM); + } + + public double ZT + { + get => _model switch + { + ReferencePointNode rp => rp.ZT, + SaveNodeNode sn => sn.MotionState.ZT, + SaveNodeWithImageNode sni => sni.MotionState.ZT, + SavePositionNode sp => sp.MotionState.ZT, + _ => 0d + }; + set => UpdateMotion(value, MotionAxis.ZT); + } + + public double ZD + { + get => _model switch + { + ReferencePointNode rp => rp.ZD, + SaveNodeNode sn => sn.MotionState.ZD, + SaveNodeWithImageNode sni => sni.MotionState.ZD, + SavePositionNode sp => sp.MotionState.ZD, + _ => 0d + }; + set => UpdateMotion(value, MotionAxis.ZD); + } + + public double TiltD + { + get => _model switch + { + ReferencePointNode rp => rp.TiltD, + SaveNodeNode sn => sn.MotionState.TiltD, + SaveNodeWithImageNode sni => sni.MotionState.TiltD, + SavePositionNode sp => sp.MotionState.TiltD, + _ => 0d + }; + set => UpdateMotion(value, MotionAxis.TiltD); + } + + public double Dist + { + get => _model switch + { + ReferencePointNode rp => rp.Dist, + SaveNodeNode sn => sn.MotionState.Dist, + SaveNodeWithImageNode sni => sni.MotionState.Dist, + SavePositionNode sp => sp.MotionState.Dist, + _ => 0d + }; + set => UpdateMotion(value, MotionAxis.Dist); + } + + public bool IsRayOn + { + get => _model switch + { + ReferencePointNode rp => rp.IsRayOn, + SaveNodeNode sn => sn.RaySourceState.IsOn, + SaveNodeWithImageNode sni => sni.RaySourceState.IsOn, + _ => false + }; + set => UpdateRaySource(isOn: value); + } + + public double Voltage + { + get => _model switch + { + ReferencePointNode rp => rp.Voltage, + SaveNodeNode sn => sn.RaySourceState.Voltage, + SaveNodeWithImageNode sni => sni.RaySourceState.Voltage, + _ => 0d + }; + set => UpdateRaySource(voltage: value); + } + + public double Current + { + get => _model is ReferencePointNode rp ? rp.Current : 0d; set { - if (SetProperty(ref _nodeType, value)) + if (_model is ReferencePointNode rp) { - // 类型变更时自动更新图标 | Auto-update icon when type changes - Icon = GetIconForNodeType(value); + UpdateModel(rp with { Current = value }); } } } - /// 节点图标路径 | Node icon path - public string Icon + public double Power { - get => _icon; - set => SetProperty(ref _icon, value); + get => _model switch + { + SaveNodeNode sn => sn.RaySourceState.Power, + SaveNodeWithImageNode sni => sni.RaySourceState.Power, + _ => 0d + }; + set => UpdateRaySource(power: value); + } + + public bool DetectorConnected + { + get => _model switch + { + SaveNodeNode sn => sn.DetectorState.IsConnected, + SaveNodeWithImageNode sni => sni.DetectorState.IsConnected, + _ => false + }; + set => UpdateDetector(isConnected: value); + } + + public bool DetectorAcquiring + { + get => _model switch + { + SaveNodeNode sn => sn.DetectorState.IsAcquiring, + SaveNodeWithImageNode sni => sni.DetectorState.IsAcquiring, + _ => false + }; + set => UpdateDetector(isAcquiring: value); + } + + public double FrameRate + { + get => _model switch + { + SaveNodeNode sn => sn.DetectorState.FrameRate, + SaveNodeWithImageNode sni => sni.DetectorState.FrameRate, + _ => 0d + }; + set => UpdateDetector(frameRate: value); + } + + public string Resolution + { + get => _model switch + { + SaveNodeNode sn => sn.DetectorState.Resolution, + SaveNodeWithImageNode sni => sni.DetectorState.Resolution, + _ => string.Empty + }; + set => UpdateDetector(resolution: value ?? string.Empty); + } + + public string ImageFileName + { + get => _model is SaveNodeWithImageNode sni ? sni.ImageFileName : string.Empty; + set + { + if (_model is SaveNodeWithImageNode sni) + { + UpdateModel(sni with { ImageFileName = value ?? string.Empty }); + } + } + } + + public string PipelineName + { + get => _model is InspectionModuleNode im ? im.Pipeline?.Name ?? string.Empty : string.Empty; + set + { + if (_model is InspectionModuleNode im) + { + var pipeline = im.Pipeline ?? new PipelineModel(); + pipeline.Name = value ?? string.Empty; + UpdateModel(im with { Pipeline = pipeline }); + } + } + } + + public PipelineModel Pipeline + { + get => _model is InspectionModuleNode im ? im.Pipeline : null; + set + { + if (_model is InspectionModuleNode im) + { + UpdateModel(im with { Pipeline = value ?? new PipelineModel() }); + } + } + } + + public string MarkerType + { + get => _model is InspectionMarkerNode mk ? mk.MarkerType : string.Empty; + set + { + if (_model is InspectionMarkerNode mk) + { + UpdateModel(mk with { MarkerType = value ?? string.Empty }); + } + } + } + + public double MarkerX + { + get => _model is InspectionMarkerNode mk ? mk.MarkerX : 0d; + set + { + if (_model is InspectionMarkerNode mk) + { + UpdateModel(mk with { MarkerX = value }); + } + } + } + + public double MarkerY + { + get => _model is InspectionMarkerNode mk ? mk.MarkerY : 0d; + set + { + if (_model is InspectionMarkerNode mk) + { + UpdateModel(mk with { MarkerY = value }); + } + } + } + + public string DialogTitle + { + get => _model is PauseDialogNode pd ? pd.DialogTitle : string.Empty; + set + { + if (_model is PauseDialogNode pd) + { + UpdateModel(pd with { DialogTitle = value ?? string.Empty }); + } + } + } + + public string DialogMessage + { + get => _model is PauseDialogNode pd ? pd.DialogMessage : string.Empty; + set + { + if (_model is PauseDialogNode pd) + { + UpdateModel(pd with { DialogMessage = value ?? string.Empty }); + } + } + } + + public int DelayMilliseconds + { + get => _model is WaitDelayNode wd ? wd.DelayMilliseconds : 0; + set + { + if (_model is WaitDelayNode wd) + { + UpdateModel(wd with { DelayMilliseconds = value }); + } + } + } + + public void ReplaceModel(CncNode model) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + Icon = GetIconForNodeType(model.NodeType); + RaiseAllPropertiesChanged(); } - /// - /// 根据节点类型返回对应的图标路径 - /// Returns the icon path for the given node type - /// public static string GetIconForNodeType(CncNodeType nodeType) { return nodeType switch { - CncNodeType.ReferencePoint => "/Resources/Icons/cnc_reference_point.png", - CncNodeType.SaveNodeWithImage => "/Resources/Icons/cnc_save_with_image.png", - CncNodeType.SaveNode => "/Resources/Icons/cnc_save_node.png", - CncNodeType.SavePosition => "/Resources/Icons/cnc_save_position.png", - CncNodeType.InspectionModule => "/Resources/Icons/cnc_inspection_module.png", - CncNodeType.InspectionMarker => "/Resources/Icons/cnc_inspection_marker.png", - CncNodeType.PauseDialog => "/Resources/Icons/cnc_pause_dialog.png", - CncNodeType.WaitDelay => "/Resources/Icons/cnc_wait_delay.png", - CncNodeType.CompleteProgram => "/Resources/Icons/cnc_complete_program.png", - _ => "/Resources/Icons/cnc_default.png", + CncNodeType.ReferencePoint => "/Assets/Icons/reference.png", + CncNodeType.SaveNodeWithImage => "/Assets/Icons/saveall.png", + CncNodeType.SaveNode => "/Assets/Icons/save.png", + CncNodeType.SavePosition => "/Assets/Icons/add-pos.png", + CncNodeType.InspectionModule => "/Assets/Icons/Module.png", + CncNodeType.InspectionMarker => "/Assets/Icons/mark.png", + CncNodeType.PauseDialog => "/Assets/Icons/message.png", + CncNodeType.WaitDelay => "/Assets/Icons/wait.png", + CncNodeType.CompleteProgram => "/Assets/Icons/finish.png", + _ => "/Assets/Icons/cnc.png", }; } + + private void UpdateMotion(double value, MotionAxis axis) + { + switch (_model) + { + case ReferencePointNode rp: + UpdateModel(axis switch + { + MotionAxis.XM => rp with { XM = value }, + MotionAxis.YM => rp with { YM = value }, + MotionAxis.ZT => rp with { ZT = value }, + MotionAxis.ZD => rp with { ZD = value }, + MotionAxis.TiltD => rp with { TiltD = value }, + MotionAxis.Dist => rp with { Dist = value }, + _ => rp + }); + break; + case SaveNodeNode sn: + UpdateModel(sn with { MotionState = UpdateMotionState(sn.MotionState, axis, value) }); + break; + case SaveNodeWithImageNode sni: + UpdateModel(sni with { MotionState = UpdateMotionState(sni.MotionState, axis, value) }); + break; + case SavePositionNode sp: + UpdateModel(sp with { MotionState = UpdateMotionState(sp.MotionState, axis, value) }); + break; + } + } + + private void UpdateRaySource(bool? isOn = null, double? voltage = null, double? current = null, double? power = null) + { + switch (_model) + { + case ReferencePointNode rp: + UpdateModel(rp with + { + IsRayOn = isOn ?? rp.IsRayOn, + Voltage = voltage ?? rp.Voltage, + Current = current ?? rp.Current + }); + break; + case SaveNodeNode sn: + UpdateModel(sn with + { + RaySourceState = sn.RaySourceState with + { + IsOn = isOn ?? sn.RaySourceState.IsOn, + Voltage = voltage ?? sn.RaySourceState.Voltage, + Power = power ?? sn.RaySourceState.Power + } + }); + break; + case SaveNodeWithImageNode sni: + UpdateModel(sni with + { + RaySourceState = sni.RaySourceState with + { + IsOn = isOn ?? sni.RaySourceState.IsOn, + Voltage = voltage ?? sni.RaySourceState.Voltage, + Power = power ?? sni.RaySourceState.Power + } + }); + break; + } + } + + private void UpdateDetector(bool? isConnected = null, bool? isAcquiring = null, double? frameRate = null, string resolution = null) + { + switch (_model) + { + case SaveNodeNode sn: + UpdateModel(sn with + { + DetectorState = sn.DetectorState with + { + IsConnected = isConnected ?? sn.DetectorState.IsConnected, + IsAcquiring = isAcquiring ?? sn.DetectorState.IsAcquiring, + FrameRate = frameRate ?? sn.DetectorState.FrameRate, + Resolution = resolution ?? sn.DetectorState.Resolution + } + }); + break; + case SaveNodeWithImageNode sni: + UpdateModel(sni with + { + DetectorState = sni.DetectorState with + { + IsConnected = isConnected ?? sni.DetectorState.IsConnected, + IsAcquiring = isAcquiring ?? sni.DetectorState.IsAcquiring, + FrameRate = frameRate ?? sni.DetectorState.FrameRate, + Resolution = resolution ?? sni.DetectorState.Resolution + } + }); + break; + } + } + + private static MotionState UpdateMotionState(MotionState state, MotionAxis axis, double value) + { + return axis switch + { + MotionAxis.XM => state with { XM = value }, + MotionAxis.YM => state with { YM = value }, + MotionAxis.ZT => state with { ZT = value }, + MotionAxis.ZD => state with { ZD = value }, + MotionAxis.TiltD => state with { TiltD = value }, + MotionAxis.Dist => state with { Dist = value }, + _ => state + }; + } + + private void UpdateModel(CncNode updatedModel) + { + _model = updatedModel ?? throw new ArgumentNullException(nameof(updatedModel)); + RaiseAllPropertiesChanged(); + _modelChangedCallback(this, updatedModel); + } + + private void RaiseAllPropertiesChanged() + { + RaisePropertyChanged(nameof(Model)); + RaisePropertyChanged(nameof(Id)); + RaisePropertyChanged(nameof(Index)); + RaisePropertyChanged(nameof(Name)); + RaisePropertyChanged(nameof(NodeType)); + RaisePropertyChanged(nameof(NodeTypeDisplay)); + RaisePropertyChanged(nameof(Icon)); + RaisePropertyChanged(nameof(IsReferencePoint)); + RaisePropertyChanged(nameof(IsSaveNode)); + RaisePropertyChanged(nameof(IsSaveNodeWithImage)); + RaisePropertyChanged(nameof(IsSavePosition)); + RaisePropertyChanged(nameof(IsInspectionModule)); + RaisePropertyChanged(nameof(IsInspectionMarker)); + RaisePropertyChanged(nameof(IsPauseDialog)); + RaisePropertyChanged(nameof(IsWaitDelay)); + RaisePropertyChanged(nameof(IsCompleteProgram)); + RaisePropertyChanged(nameof(IsPositionChild)); + RaisePropertyChanged(nameof(IsMotionSnapshotNode)); + RaisePropertyChanged(nameof(RelationTag)); + RaisePropertyChanged(nameof(XM)); + RaisePropertyChanged(nameof(YM)); + RaisePropertyChanged(nameof(ZT)); + RaisePropertyChanged(nameof(ZD)); + RaisePropertyChanged(nameof(TiltD)); + RaisePropertyChanged(nameof(Dist)); + RaisePropertyChanged(nameof(IsRayOn)); + RaisePropertyChanged(nameof(Voltage)); + RaisePropertyChanged(nameof(Current)); + RaisePropertyChanged(nameof(Power)); + RaisePropertyChanged(nameof(DetectorConnected)); + RaisePropertyChanged(nameof(DetectorAcquiring)); + RaisePropertyChanged(nameof(FrameRate)); + RaisePropertyChanged(nameof(Resolution)); + RaisePropertyChanged(nameof(ImageFileName)); + RaisePropertyChanged(nameof(Pipeline)); + RaisePropertyChanged(nameof(PipelineName)); + RaisePropertyChanged(nameof(MarkerType)); + RaisePropertyChanged(nameof(MarkerX)); + RaisePropertyChanged(nameof(MarkerY)); + RaisePropertyChanged(nameof(DialogTitle)); + RaisePropertyChanged(nameof(DialogMessage)); + RaisePropertyChanged(nameof(DelayMilliseconds)); + } + + private enum MotionAxis + { + XM, + YM, + ZT, + ZD, + TiltD, + Dist + } } -} \ No newline at end of file +} diff --git a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs new file mode 100644 index 0000000..6bdcc74 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs @@ -0,0 +1,30 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; + +namespace XplorePlane.ViewModels +{ + public interface IPipelineEditorHostViewModel + { + ObservableCollection PipelineNodes { get; } + + PipelineNodeViewModel SelectedNode { get; set; } + + string StatusMessage { get; } + + ICommand AddOperatorCommand { get; } + + ICommand RemoveOperatorCommand { get; } + + ICommand MoveNodeUpCommand { get; } + + ICommand MoveNodeDownCommand { get; } + + ICommand NewPipelineCommand { get; } + + ICommand SavePipelineCommand { get; } + + ICommand SaveAsPipelineCommand { get; } + + ICommand LoadPipelineCommand { get; } + } +} diff --git a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs index 6ba4b19..618f111 100644 --- a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Windows.Input; using System.Windows.Media.Imaging; using XP.Common.Logging.Interfaces; using XplorePlane.Events; @@ -16,7 +17,7 @@ using XplorePlane.Services; namespace XplorePlane.ViewModels { - public class PipelineEditorViewModel : BindableBase + public class PipelineEditorViewModel : BindableBase, IPipelineEditorHostViewModel { private const int MaxPipelineLength = 20; private const int DebounceDelayMs = 300; @@ -165,6 +166,15 @@ namespace XplorePlane.ViewModels public DelegateCommand MoveNodeUpCommand { get; } public DelegateCommand MoveNodeDownCommand { get; } + ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand; + ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand; + ICommand IPipelineEditorHostViewModel.MoveNodeUpCommand => MoveNodeUpCommand; + ICommand IPipelineEditorHostViewModel.MoveNodeDownCommand => MoveNodeDownCommand; + ICommand IPipelineEditorHostViewModel.NewPipelineCommand => NewPipelineCommand; + ICommand IPipelineEditorHostViewModel.SavePipelineCommand => SavePipelineCommand; + ICommand IPipelineEditorHostViewModel.SaveAsPipelineCommand => SaveAsPipelineCommand; + ICommand IPipelineEditorHostViewModel.LoadPipelineCommand => LoadPipelineCommand; + // ── Command Implementations ─────────────────────────────────── private bool CanAddOperator(string operatorKey) => diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 448552b..1809723 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -1,4 +1,4 @@ -using Prism.Commands; +using Prism.Commands; using Prism.Events; using Prism.Ioc; using Prism.Mvvm; @@ -10,6 +10,9 @@ using System.IO; using System.Windows; using System.Windows.Media.Imaging; using XplorePlane.Events; +using XplorePlane.ViewModels.Cnc; +using XplorePlane.Views; +using XplorePlane.Views.Cnc; using XP.Common.Logging.Interfaces; using XP.Common.PdfViewer.Interfaces; using XP.Hardware.MotionControl.Abstractions; @@ -18,9 +21,12 @@ namespace XplorePlane.ViewModels { public class MainViewModel : BindableBase { + private const double CncEditorHostWidth = 710d; private readonly ILoggerService _logger; private readonly IContainerProvider _containerProvider; private readonly IEventAggregator _eventAggregator; + private readonly CncEditorViewModel _cncEditorViewModel; + private readonly CncPageView _cncPageView; private string _licenseInfo = "当前时间"; public string LicenseInfo @@ -49,6 +55,17 @@ namespace XplorePlane.ViewModels public DelegateCommand OpenLibraryVersionsCommand { get; } public DelegateCommand OpenUserManualCommand { get; } public DelegateCommand OpenCameraSettingsCommand { get; } + public DelegateCommand NewCncProgramCommand { get; } + public DelegateCommand SaveCncProgramCommand { get; } + public DelegateCommand LoadCncProgramCommand { get; } + public DelegateCommand InsertReferencePointCommand { get; } + public DelegateCommand InsertSavePositionCommand { get; } + public DelegateCommand InsertCompleteProgramCommand { get; } + public DelegateCommand InsertInspectionMarkerCommand { get; } + public DelegateCommand InsertInspectionModuleCommand { get; } + public DelegateCommand InsertSaveNodeCommand { get; } + public DelegateCommand InsertPauseDialogCommand { get; } + public DelegateCommand InsertWaitDelayCommand { get; } // 硬件命令 public DelegateCommand AxisResetCommand { get; } @@ -62,6 +79,27 @@ namespace XplorePlane.ViewModels public DelegateCommand OpenLanguageSwitcherCommand { get; } public DelegateCommand OpenRealTimeLogViewerCommand { get; } + /// 右侧图像区域内容 | Right-side image panel content + public object ImagePanelContent + { + get => _imagePanelContent; + set => SetProperty(ref _imagePanelContent, value); + } + + /// 右侧图像区域宽度 | Right-side image panel width + public GridLength ImagePanelWidth + { + get => _imagePanelWidth; + set => SetProperty(ref _imagePanelWidth, value); + } + + /// 主视图区宽度 | Main viewport width + public GridLength ViewportPanelWidth + { + get => _viewportPanelWidth; + set => SetProperty(ref _viewportPanelWidth, value); + } + // 窗口引用(单例窗口防止重复打开) private Window _motionDebugWindow; private Window _detectorConfigWindow; @@ -69,12 +107,18 @@ namespace XplorePlane.ViewModels private Window _realTimeLogViewerWindow; private Window _toolboxWindow; private Window _raySourceConfigWindow; + private object _imagePanelContent; + private GridLength _viewportPanelWidth = new GridLength(1, GridUnitType.Star); + private GridLength _imagePanelWidth = new GridLength(320); + private bool _isCncEditorMode; public MainViewModel(ILoggerService logger, IContainerProvider containerProvider, IEventAggregator eventAggregator) { _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); _containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider)); _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); + _cncEditorViewModel = _containerProvider.Resolve(); + _cncPageView = new CncPageView { DataContext = _cncEditorViewModel }; NavigationTree = new ObservableCollection(); @@ -90,12 +134,23 @@ namespace XplorePlane.ViewModels OpenImageProcessingCommand = new DelegateCommand(() => ShowWindow(new Views.ImageProcessingWindow(), "图像处理")); LoadImageCommand = new DelegateCommand(ExecuteLoadImage); OpenPipelineEditorCommand = new DelegateCommand(() => ShowWindow(new Views.PipelineEditorWindow(), "流水线编辑器")); - OpenCncEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.CncEditorWindow(), "CNC 编辑器")); + OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor); OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编排")); OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox); OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于")); OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual); OpenCameraSettingsCommand = new DelegateCommand(ExecuteOpenCameraSettings); + NewCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.NewProgramCommand.Execute())); + SaveCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.SaveProgramCommand.Execute())); + LoadCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.LoadProgramCommand.Execute())); + InsertReferencePointCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertReferencePointCommand.Execute())); + InsertSavePositionCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSavePositionCommand.Execute())); + InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertCompleteProgramCommand.Execute())); + InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertInspectionMarkerCommand.Execute())); + InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertInspectionModuleCommand.Execute())); + InsertSaveNodeCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSaveNodeCommand.Execute())); + InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute())); + InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute())); // 硬件命令 AxisResetCommand = new DelegateCommand(ExecuteAxisReset); @@ -109,6 +164,10 @@ namespace XplorePlane.ViewModels OpenLanguageSwitcherCommand = new DelegateCommand(ExecuteOpenLanguageSwitcher); OpenRealTimeLogViewerCommand = new DelegateCommand(ExecuteOpenRealTimeLogViewer); + ImagePanelContent = new PipelineEditorView(); + ViewportPanelWidth = new GridLength(1, GridUnitType.Star); + ImagePanelWidth = new GridLength(320); + _logger.Info("MainViewModel 已初始化"); } @@ -153,6 +212,38 @@ namespace XplorePlane.ViewModels ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "算子工具箱"); } + private void ExecuteOpenCncEditor() + { + if (_isCncEditorMode) + { + ImagePanelContent = new PipelineEditorView(); + ViewportPanelWidth = new GridLength(1, GridUnitType.Star); + ImagePanelWidth = new GridLength(320); + _isCncEditorMode = false; + _logger.Info("已退出 CNC 编辑模式"); + return; + } + + ShowCncEditor(); + } + + private void ExecuteCncEditorAction(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + ShowCncEditor(); + action(_cncEditorViewModel); + } + + private void ShowCncEditor() + { + ImagePanelContent = _cncPageView; + ViewportPanelWidth = new GridLength(1, GridUnitType.Star); + ImagePanelWidth = new GridLength(CncEditorHostWidth); + _isCncEditorMode = true; + _logger.Info("CNC 编辑器已切换到主界面图像区域"); + } + private void ExecuteOpenUserManual() { try diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml b/XplorePlane/Views/Cnc/CncEditorWindow.xaml index 0f00806..a709fcf 100644 --- a/XplorePlane/Views/Cnc/CncEditorWindow.xaml +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml @@ -1,12 +1,15 @@ - - \ No newline at end of file + diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index f16e760..975b87d 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -1,297 +1,497 @@ - - - - - - - Microsoft YaHei UI + + - - - - + + + + + + + + - - - - - - + + + + + + + - - - - -