ZHANG Zhengxuan
2026-04-23 08:22:33 +08:00
27 changed files with 3528 additions and 599 deletions
@@ -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": {
@@ -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<ILoggerService> _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<ILoggerService>();
_mockLogger.Setup(l => l.ForModule(It.IsAny<string>())).Returns(_mockLogger.Object);
_mockLogger.Setup(l => l.ForModule<InspectionResultStore>()).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));
}
}
}
@@ -34,11 +34,4 @@
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="ImageProcessing.Core">
<HintPath>..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
</Project>
+3 -1
View File
@@ -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<ICncProgramService, CncProgramService>();
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
// ── CNC / 矩阵 ViewModel(瞬态)──
containerRegistry.Register<CncEditorViewModel>();
@@ -419,4 +421,4 @@ namespace XplorePlane
base.ConfigureModuleCatalog(moduleCatalog);
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

+3 -2
View File
@@ -46,7 +46,8 @@ namespace XplorePlane.Models
/// <summary>参考点节点 | Reference point node</summary>
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);
/// <summary>保存节点(含图像)| Save node with image</summary>
@@ -113,4 +114,4 @@ namespace XplorePlane.Models
DateTime UpdatedAt,
IReadOnlyList<CncNode> Nodes
);
}
}
@@ -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<InspectionNodeResult> Nodes { get; set; } = Array.Empty<InspectionNodeResult>();
public IReadOnlyList<InspectionMetricResult> Metrics { get; set; } = Array.Empty<InspectionMetricResult>();
public IReadOnlyList<InspectionAssetRecord> Assets { get; set; } = Array.Empty<InspectionAssetRecord>();
public IReadOnlyList<PipelineExecutionSnapshot> PipelineSnapshots { get; set; } = Array.Empty<PipelineExecutionSnapshot>();
}
}
+56 -2
View File
@@ -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<CncProgramService>();
_logger.Info("CncProgramService 已初始化 | CncProgramService initialized");
@@ -200,6 +205,32 @@ namespace XplorePlane.Services.Cnc
return updated;
}
/// <inheritdoc />
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<CncNode>(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;
}
/// <inheritdoc />
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());
}
/// <summary>创建保存节点(含图像)| Create save node with image</summary>
@@ -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;
}
}
}
}
+4 -12
View File
@@ -4,36 +4,28 @@ using XplorePlane.Models;
namespace XplorePlane.Services.Cnc
{
/// <summary>
/// CNC 程序管理服务接口,负责程序的创建、节点编辑、序列化/反序列化和文件读写
/// CNC program management service interface for creation, node editing, serialization and file I/O
/// CNC program management service interface.
/// </summary>
public interface ICncProgramService
{
/// <summary>创建空的 CNC 程序 | Create an empty CNC program</summary>
CncProgram CreateProgram(string name);
/// <summary>根据节点类型创建节点(从 IAppStateService 捕获设备状态)| Create a node by type (captures device state from IAppStateService)</summary>
CncNode CreateNode(CncNodeType type);
/// <summary>在指定索引之后插入节点并重新编号 | Insert a node after the given index and renumber</summary>
CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node);
/// <summary>移除指定索引的节点并重新编号 | Remove the node at the given index and renumber</summary>
CncProgram RemoveNode(CncProgram program, int index);
/// <summary>将节点从旧索引移动到新索引并重新编号 | Move a node from old index to new index and renumber</summary>
CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex);
/// <summary>将 CNC 程序保存到 .xp 文件 | Save CNC program to .xp file</summary>
CncProgram UpdateNode(CncProgram program, int index, CncNode node);
Task SaveAsync(CncProgram program, string filePath);
/// <summary>从 .xp 文件加载 CNC 程序 | Load CNC program from .xp file</summary>
Task<CncProgram> LoadAsync(string filePath);
/// <summary>将 CNC 程序序列化为 JSON 字符串 | Serialize CNC program to JSON string</summary>
string Serialize(CncProgram program);
/// <summary>从 JSON 字符串反序列化 CNC 程序 | Deserialize CNC program from JSON string</summary>
CncProgram Deserialize(string json);
}
}
}
@@ -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<InspectionMetricResult> metrics = null,
PipelineExecutionSnapshot pipelineSnapshot = null,
IEnumerable<InspectionAssetWriteRequest> assets = null);
Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null);
Task<IReadOnlyList<InspectionRunRecord>> QueryRunsAsync(InspectionRunQuery query = null);
Task<InspectionRunDetail> GetRunDetailAsync(Guid runId);
}
}
File diff suppressed because it is too large Load Diff
+321 -164
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<CncNodeViewModel> _nodes;
private ObservableCollection<CncNodeViewModel> _treeNodes;
private CncNodeViewModel _selectedNode;
private bool _isModified;
private string _programName;
private Guid? _preferredSelectedNodeId;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
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<CncEditorViewModel>();
_nodes = new ObservableCollection<CncNodeViewModel>();
_treeNodes = new ObservableCollection<CncNodeViewModel>();
// ── 节点插入命令 | 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<CncNodeViewModel>(ExecuteMoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<CncNodeViewModel>(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 ──────────────────────────────────────────
/// <summary>节点列表 | Node list</summary>
public ObservableCollection<CncNodeViewModel> Nodes
{
get => _nodes;
set => SetProperty(ref _nodes, value);
private set => SetProperty(ref _nodes, value);
}
public ObservableCollection<CncNodeViewModel> TreeNodes
{
get => _treeNodes;
private set => SetProperty(ref _treeNodes, value);
}
/// <summary>当前选中的节点 | Currently selected node</summary>
public CncNodeViewModel SelectedNode
{
get => _selectedNode;
set => SetProperty(ref _selectedNode, value);
set
{
if (SetProperty(ref _selectedNode, value))
{
RaisePropertyChanged(nameof(HasSelection));
}
}
}
/// <summary>程序是否已修改 | Whether the program has been modified</summary>
public bool HasSelection => SelectedNode != null;
public bool IsModified
{
get => _isModified;
set => SetProperty(ref _isModified, value);
}
/// <summary>当前程序名称 | Current program name</summary>
public string ProgramName
{
get => _programName;
set => SetProperty(ref _programName, value);
}
// ── 节点插入命令 | Node insertion commands ──────────────────────
/// <summary>插入参考点命令 | Insert reference point command</summary>
public DelegateCommand InsertReferencePointCommand { get; }
/// <summary>插入保存节点(含图像)命令 | Insert save node with image command</summary>
public DelegateCommand InsertSaveNodeWithImageCommand { get; }
/// <summary>插入保存节点命令 | Insert save node command</summary>
public DelegateCommand InsertSaveNodeCommand { get; }
/// <summary>插入保存位置命令 | Insert save position command</summary>
public DelegateCommand InsertSavePositionCommand { get; }
/// <summary>插入检测模块命令 | Insert inspection module command</summary>
public DelegateCommand InsertInspectionModuleCommand { get; }
/// <summary>插入检测标记命令 | Insert inspection marker command</summary>
public DelegateCommand InsertInspectionMarkerCommand { get; }
/// <summary>插入停顿对话框命令 | Insert pause dialog command</summary>
public DelegateCommand InsertPauseDialogCommand { get; }
/// <summary>插入等待延时命令 | Insert wait delay command</summary>
public DelegateCommand InsertWaitDelayCommand { get; }
/// <summary>插入完成程序命令 | Insert complete program command</summary>
public DelegateCommand InsertCompleteProgramCommand { get; }
// ── 节点编辑命令 | Node editing commands ────────────────────────
/// <summary>删除选中节点命令 | Delete selected node command</summary>
public DelegateCommand DeleteNodeCommand { get; }
/// <summary>上移节点命令 | Move node up command</summary>
public DelegateCommand<CncNodeViewModel> MoveNodeUpCommand { get; }
/// <summary>下移节点命令 | Move node down command</summary>
public DelegateCommand<CncNodeViewModel> MoveNodeDownCommand { get; }
// ── 文件操作命令 | File operation commands ──────────────────────
/// <summary>保存程序命令 | Save program command</summary>
public DelegateCommand SaveProgramCommand { get; }
/// <summary>加载程序命令 | Load program command</summary>
public DelegateCommand LoadProgramCommand { get; }
/// <summary>新建程序命令 | New program command</summary>
public DelegateCommand NewProgramCommand { get; }
/// <summary>导出 CSV 命令 | Export CSV command</summary>
public DelegateCommand ExportCsvCommand { get; }
// ── 命令执行方法 | Command execution methods ────────────────────
/// <summary>
/// 插入指定类型的节点到选中节点之后
/// Insert a node of the specified type after the selected node
/// </summary>
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);
}
}
/// <summary>
/// 删除选中节点 | Delete the selected node
/// </summary>
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);
}
}
/// <summary>
/// 判断是否可以删除节点(至少保留 1 个节点)
/// Determines whether delete is allowed (at least 1 node must remain)
/// </summary>
private bool CanExecuteDeleteNode()
{
return SelectedNode != null
@@ -223,9 +186,6 @@ namespace XplorePlane.ViewModels.Cnc
&& _currentProgram.Nodes.Count > 1;
}
/// <summary>
/// 上移节点 | Move node up
/// </summary>
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);
}
}
/// <summary>
/// 下移节点 | Move node down
/// </summary>
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);
}
}
/// <summary>
/// 保存当前程序到文件 | Save current program to file
/// </summary>
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");
}
}
/// <summary>
/// 从文件加载程序 | Load program from file
/// </summary>
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");
}
}
/// <summary>
/// 创建新程序 | Create a new program
/// </summary>
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);
}
/// <summary>
/// 导出当前程序为 CSV 文件 | Export current program to CSV file
/// </summary>
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");
}
}
/// <summary>
/// CSV 字段转义:含逗号、引号或换行时用双引号包裹
/// Escape CSV field: wrap with double quotes if it contains comma, quote, or newline
/// </summary>
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 ───────────────────────────────────
/// <summary>
/// 编辑操作后的统一处理:刷新节点、标记已修改、发布变更事件
/// Unified post-edit handling: refresh nodes, mark modified, publish change event
/// </summary>
private void OnProgramEdited()
{
IsModified = true;
@@ -431,34 +366,256 @@ namespace XplorePlane.ViewModels.Cnc
PublishProgramChanged();
}
/// <summary>
/// 从 _currentProgram.Nodes 重建 Nodes 集合
/// Rebuild the Nodes collection from _currentProgram.Nodes
/// </summary>
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<CncNodeViewModel>();
var rootNodes = new List<CncNodeViewModel>();
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<CncNodeViewModel>(flatNodes);
TreeNodes = new ObservableCollection<CncNodeViewModel>(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<CncNode> 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<CncNode> 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;
}
/// <summary>
/// 通过 IEventAggregator 发布 CNC 程序变更事件
/// Publish CNC program changed event via IEventAggregator
/// </summary>
private void PublishProgramChanged()
{
_eventAggregator
@@ -466,4 +623,4 @@ namespace XplorePlane.ViewModels.Cnc
.Publish(new CncProgramChangedPayload(ProgramName ?? string.Empty, IsModified));
}
}
}
}
@@ -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<CncInspectionModulePipelineViewModel>();
PipelineNodes = new ObservableCollection<PipelineNodeViewModel>();
AddOperatorCommand = new DelegateCommand<string>(AddOperator, _ => HasActiveModule);
RemoveOperatorCommand = new DelegateCommand<PipelineNodeViewModel>(RemoveOperator);
MoveNodeUpCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<PipelineNodeViewModel>(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<PipelineNodeViewModel> 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<PipelineNodeModel>())
.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<string, object> 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<string>)?.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();
}
}
}
}
+514 -50
View File
@@ -1,89 +1,553 @@
using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
using XplorePlane.Models;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// 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.
/// </summary>
public class CncNodeViewModel : BindableBase
{
private int _index;
private string _name;
private CncNodeType _nodeType;
private readonly Action<CncNodeViewModel, CncNode> _modelChangedCallback;
private CncNode _model;
private string _icon;
private bool _isExpanded = true;
/// <summary>
/// 构造函数,从 CncNode 模型初始化 ViewModel
/// Constructor that initializes the ViewModel from a CncNode model
/// </summary>
public CncNodeViewModel(CncNode model)
public CncNodeViewModel(CncNode model, Action<CncNodeViewModel, CncNode> 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<CncNodeViewModel>();
}
/// <summary>底层 CNC 节点模型(只读)| Underlying CNC node model (read-only)</summary>
public CncNode Model { get; }
public ObservableCollection<CncNodeViewModel> Children { get; }
/// <summary>节点在程序中的索引 | Node index in the program</summary>
public int Index
{
get => _index;
set => SetProperty(ref _index, value);
}
public CncNode Model => _model;
public Guid Id => _model.Id;
public int Index => _model.Index;
/// <summary>节点显示名称 | Node display name</summary>
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
get => _model.Name;
set => UpdateModel(_model with { Name = value ?? string.Empty });
}
/// <summary>节点类型 | Node type</summary>
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 });
}
}
}
/// <summary>节点图标路径 | Node icon path</summary>
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();
}
/// <summary>
/// 根据节点类型返回对应的图标路径
/// Returns the icon path for the given node type
/// </summary>
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
}
}
}
}
@@ -0,0 +1,30 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace XplorePlane.ViewModels
{
public interface IPipelineEditorHostViewModel
{
ObservableCollection<PipelineNodeViewModel> 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; }
}
}
@@ -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<PipelineNodeViewModel> MoveNodeUpCommand { get; }
public DelegateCommand<PipelineNodeViewModel> 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) =>
+93 -2
View File
@@ -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; }
/// <summary>右侧图像区域内容 | Right-side image panel content</summary>
public object ImagePanelContent
{
get => _imagePanelContent;
set => SetProperty(ref _imagePanelContent, value);
}
/// <summary>右侧图像区域宽度 | Right-side image panel width</summary>
public GridLength ImagePanelWidth
{
get => _imagePanelWidth;
set => SetProperty(ref _imagePanelWidth, value);
}
/// <summary>主视图区宽度 | Main viewport width</summary>
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<MainViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_cncEditorViewModel = _containerProvider.Resolve<CncEditorViewModel>();
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
NavigationTree = new ObservableCollection<object>();
@@ -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<CncEditorViewModel> 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
+7 -4
View File
@@ -1,12 +1,15 @@
<Window
<Window
x:Class="XplorePlane.Views.Cnc.CncEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cnc="clr-namespace:XplorePlane.Views.Cnc"
Title="CNC 编辑器"
Width="350"
Height="750"
Width="1040"
Height="780"
MinWidth="960"
MinHeight="720"
ResizeMode="CanResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner">
<cnc:CncPageView />
</Window>
</Window>
+449 -249
View File
@@ -1,297 +1,497 @@
<!-- CNC 编辑器主页面视图 | CNC editor main page view -->
<UserControl
x:Class="XplorePlane.Views.Cnc.CncPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:XplorePlane.Views.Cnc"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:prism="http://prismlibrary.com/"
d:DesignHeight="700"
d:DesignWidth="350"
xmlns:views="clr-namespace:XplorePlane.Views"
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
d:DesignHeight="760"
d:DesignWidth="702"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d">
<UserControl.Resources>
<!-- 面板背景和边框颜色 | Panel background and border colors -->
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#cdcbcb" />
<SolidColorBrush x:Key="SeparatorBrush" Color="#E0E0E0" />
<SolidColorBrush x:Key="AccentBlue" Color="#E3F0FF" />
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<local:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<!-- 节点列表项样式 | Node list item style -->
<Style x:Key="CncNodeItemStyle" TargetType="ListBoxItem">
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#CDCBCB" />
<SolidColorBrush x:Key="SeparatorBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="HeaderBg" Color="#F7F7F7" />
<SolidColorBrush x:Key="TreeParentBg" Color="#F6F8FB" />
<SolidColorBrush x:Key="TreeChildBg" Color="#FBFCFE" />
<SolidColorBrush x:Key="TreeAccentBrush" Color="#2E6FA3" />
<SolidColorBrush x:Key="TreeChildLineBrush" Color="#B9CDE0" />
<FontFamily x:Key="UiFont">Microsoft YaHei UI</FontFamily>
<Style x:Key="TreeItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<!-- 工具栏按钮样式 | Toolbar button style -->
<Style x:Key="ToolbarBtn" TargetType="Button">
<Setter Property="Height" Value="28" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Padding" Value="6,0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="#cdcbcb" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Style x:Key="EditorTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<Style x:Key="LabelStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#666666" />
<Setter Property="Margin" Value="0,0,0,3" />
</Style>
<Style x:Key="EditorBox" TargetType="TextBox">
<Setter Property="Height" Value="28" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="BorderBrush" Value="#CFCFCF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="EditorCheck" TargetType="CheckBox">
<Setter Property="Margin" Value="0,2,0,8" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="CompactGroupBox" TargetType="GroupBox">
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</UserControl.Resources>
<Border
Width="702"
MinWidth="702"
HorizontalAlignment="Left"
Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.RowDefinitions>
<!-- Row 0: 工具栏 | Toolbar -->
<RowDefinition Height="Auto" />
<!-- Row 1: 主内容区(左侧节点列表 + 右侧参数面板)| Main content (left: node list, right: parameter panel) -->
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="250" />
</Grid.ColumnDefinitions>
<!-- ═══ 工具栏:节点插入命令 + 文件操作命令 | Toolbar: node insert commands + file operation commands ═══ -->
<Border
Grid.Row="0"
Padding="6,4"
Background="#F5F5F5"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1">
<WrapPanel Orientation="Horizontal">
<!-- 文件操作按钮 | File operation buttons -->
<Button
Command="{Binding NewProgramCommand}"
Content="新建"
Style="{StaticResource ToolbarBtn}"
ToolTip="新建程序 | New Program" />
<Button
Command="{Binding SaveProgramCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存程序 | Save Program" />
<Button
Command="{Binding LoadProgramCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载程序 | Load Program" />
<Button
Command="{Binding ExportCsvCommand}"
Content="导出CSV"
Style="{StaticResource ToolbarBtn}"
ToolTip="导出 CSV | Export CSV" />
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- 分隔线 | Separator -->
<Rectangle
Width="1"
Height="20"
Margin="4,0"
Fill="{StaticResource SeparatorBrush}" />
<Border
Grid.Row="0"
Padding="10,8"
Background="{StaticResource HeaderBg}"
BorderBrush="{StaticResource SeparatorBrush}"
BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="{Binding ProgramName, TargetNullValue=CNC编辑}"
TextWrapping="Wrap" />
<TextBlock
Margin="0,3,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="保存位置节点下会自动显示检测标记和检测模块,清晰表达当前检测位置与检测内容的父子关系。"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- 节点插入按钮(9 种节点类型)| Node insert buttons (9 node types) -->
<Button
Command="{Binding InsertReferencePointCommand}"
Content="参考点"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入参考点 | Insert Reference Point" />
<Button
Command="{Binding InsertSaveNodeWithImageCommand}"
Content="保存+图"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存节点并保存图片 | Save Node With Image" />
<Button
Command="{Binding InsertSaveNodeCommand}"
Content="保存节点"
Style="{StaticResource ToolbarBtn}"
ToolTip="仅保存节点 | Save Node" />
<Button
Command="{Binding InsertSavePositionCommand}"
Content="保存位置"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存位置 | Save Position" />
<Button
Command="{Binding InsertInspectionModuleCommand}"
Content="检测模块"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入检测模块 | Insert Inspection Module" />
<Button
Command="{Binding InsertInspectionMarkerCommand}"
Content="检测标记"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入检测标记 | Insert Inspection Marker" />
<Button
Command="{Binding InsertPauseDialogCommand}"
Content="停顿"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入停顿对话框 | Insert Pause Dialog" />
<Button
Command="{Binding InsertWaitDelayCommand}"
Content="延时"
Style="{StaticResource ToolbarBtn}"
ToolTip="设置等待延时 | Insert Wait Delay" />
<Button
Command="{Binding InsertCompleteProgramCommand}"
Content="完成"
Style="{StaticResource ToolbarBtn}"
ToolTip="完成程序 | Complete Program" />
</WrapPanel>
</Border>
<!-- ═══ 主内容区:左侧节点列表 + 右侧参数面板 | Main content: left node list + right parameter panel ═══ -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<!-- 左侧:节点列表 | Left: node list -->
<ColumnDefinition Width="3*" MinWidth="150" />
<!-- 分隔线 | Splitter -->
<ColumnDefinition Width="Auto" />
<!-- 右侧:参数面板 | Right: parameter panel -->
<ColumnDefinition Width="2*" MinWidth="150" />
</Grid.ColumnDefinitions>
<!-- ── 左侧:CNC 节点列表 | Left: CNC node list ── -->
<ListBox
x:Name="CncNodeListBox"
Grid.Column="0"
<TreeView
x:Name="CncTreeView"
Grid.Row="1"
Padding="4,6"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{StaticResource CncNodeItemStyle}"
ItemsSource="{Binding Nodes}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid x:Name="NodeRoot" MinHeight="40">
<Grid.ColumnDefinitions>
<!-- 图标列 | Icon column -->
<ColumnDefinition Width="40" />
<!-- 名称列 | Name column -->
<ColumnDefinition Width="*" />
<!-- 操作按钮列 | Action buttons column -->
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
ItemsSource="{Binding TreeNodes}"
SelectedItemChanged="CncTreeView_SelectedItemChanged">
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type vm:CncNodeViewModel}" ItemsSource="{Binding Children}">
<Border
x:Name="NodeCard"
Margin="0,1,0,1"
Padding="0"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="4">
<Grid x:Name="NodeRoot" MinHeight="28">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="18" />
<ColumnDefinition Width="24" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- 节点图标 | Node icon -->
<Border
Grid.Column="0"
Width="28"
Height="28"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="#E8F0FE"
BorderBrush="#5B9BD5"
BorderThickness="1.5"
CornerRadius="4">
<Image
Width="20"
Height="20"
Source="{Binding Icon}"
Stretch="Uniform" />
</Border>
<Grid Grid.Column="0">
<Border
x:Name="ChildStem"
Width="2"
Margin="8,0,0,0"
HorizontalAlignment="Left"
Background="{StaticResource TreeChildLineBrush}"
Visibility="Collapsed" />
<Border
x:Name="ChildBranch"
Width="10"
Height="2"
Margin="8,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Background="{StaticResource TreeChildLineBrush}"
Visibility="Collapsed" />
</Grid>
<!-- 节点序号和名称 | Node index and name -->
<StackPanel
Grid.Column="1"
Margin="6,0,0,0"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
<Border
Grid.Column="1"
Width="18"
Height="18"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="11"
Foreground="#888"
Text="{Binding Index, StringFormat='[{0}] '}" />
<TextBlock
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="12"
Text="{Binding Name}" />
</StackPanel>
Background="Transparent"
CornerRadius="4">
<Image
Width="14"
Height="14"
Source="{Binding Icon}"
Stretch="Uniform" />
</Border>
<!-- 悬停操作按钮:上移 / 下移 / 删除 | Hover actions: MoveUp / MoveDown / Delete -->
<StackPanel
x:Name="NodeActions"
Grid.Column="2"
Margin="0,0,4,0"
VerticalAlignment="Center"
Orientation="Horizontal"
Visibility="Collapsed">
<Button
Width="22"
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#cdcbcb"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content="▲"
Cursor="Hand"
FontSize="10"
ToolTip="上移 | Move Up" />
<Button
Width="22"
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#cdcbcb"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content="▼"
Cursor="Hand"
FontSize="10"
ToolTip="下移 | Move Down" />
<Button
Width="22"
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#E05050"
BorderThickness="1"
Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
Content="✕"
Cursor="Hand"
FontSize="10"
ToolTip="删除 | Delete" />
</StackPanel>
</Grid>
<StackPanel
Grid.Column="2"
Margin="3,0,0,0"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="10.5"
Foreground="#888888"
Text="{Binding Index, StringFormat='[{0}] '}" />
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11.5"
FontWeight="SemiBold"
Text="{Binding Name}" />
</StackPanel>
<StackPanel
x:Name="NodeActions"
Grid.Column="3"
Margin="0,0,2,0"
VerticalAlignment="Center"
Orientation="Horizontal"
Visibility="Collapsed">
<Button
Width="20"
Height="20"
Margin="1,0"
Background="White"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
CommandParameter="{Binding}"
Content="↑"
Cursor="Hand"
FontSize="10"
ToolTip="上移" />
<Button
Width="20"
Height="20"
Margin="1,0"
Background="White"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
CommandParameter="{Binding}"
Content="↓"
Cursor="Hand"
FontSize="10"
ToolTip="下移" />
<Button
Width="20"
Height="20"
Margin="1,0"
Background="White"
BorderBrush="#E05050"
BorderThickness="1"
Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
Content="×"
Cursor="Hand"
FontSize="10"
ToolTip="删除" />
</StackPanel>
</Grid>
</Border>
<DataTemplate.Triggers>
<!-- 鼠标悬停时显示操作按钮 | Show action buttons on mouse hover -->
<Trigger SourceName="NodeRoot" Property="IsMouseOver" Value="True">
<Setter TargetName="NodeActions" Property="Visibility" Value="Visible" />
<Setter TargetName="NodeCard" Property="Background" Value="#F3F7FB" />
<Setter TargetName="NodeCard" Property="BorderBrush" Value="#D7E4F1" />
</Trigger>
<DataTrigger Binding="{Binding IsPositionChild}" Value="True">
<Setter TargetName="ChildStem" Property="Visibility" Value="Visible" />
<Setter TargetName="ChildBranch" Property="Visibility" Value="Visible" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</HierarchicalDataTemplate>
</TreeView.Resources>
<TreeView.ItemContainerStyle>
<Style BasedOn="{StaticResource TreeItemStyle}" TargetType="TreeViewItem" />
</TreeView.ItemContainerStyle>
</TreeView>
</Grid>
<!-- 垂直分隔线 | Vertical separator -->
<Rectangle
Grid.Column="1"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<Rectangle
Grid.Column="1"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<!-- ── 右侧:参数面板(根据节点类型动态渲染)| Right: parameter panel (dynamic rendering by node type) ── -->
<ScrollViewer
Grid.Column="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,6">
<TextBlock
Margin="0,0,0,4"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="参数配置" />
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<StackPanel Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}}">
<TextBlock Style="{StaticResource EditorTitle}" Text="节点属性" />
<!-- 动态参数内容区域(占位:根据 SelectedNode 类型渲染)| Dynamic parameter content area (placeholder for node-type-based rendering) -->
<ContentControl Content="{Binding SelectedNode}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Name, UpdateSourceTrigger=PropertyChanged}" />
<UniformGrid Margin="0,0,0,8" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="索引" />
<TextBox Style="{StaticResource EditorBox}" IsReadOnly="True" Text="{Binding SelectedNode.Index, Mode=OneWay}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="类型" />
<TextBox Style="{StaticResource EditorBox}" IsReadOnly="True" Text="{Binding SelectedNode.NodeTypeDisplay, Mode=OneWay}" />
</StackPanel>
</UniformGrid>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="运动参数"
Visibility="{Binding SelectedNode.IsMotionSnapshotNode, Converter={StaticResource BoolToVisibilityConverter}}">
<UniformGrid Margin="8,8,8,6" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="XM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.XM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="YM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.YM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="ZT" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZT, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="ZD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="TiltD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.TiltD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="Dist" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Dist, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</UniformGrid>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="射线源"
Visibility="{Binding SelectedNode.IsReferencePoint, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电流 (uA)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Current, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNode, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNodeWithImage, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="图像文件" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ImageFileName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="检测模块"
Visibility="{Binding SelectedNode.IsInspectionModule, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="Pipeline 名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.PipelineName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="检测标记"
Visibility="{Binding SelectedNode.IsInspectionMarker, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标记类型" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerType, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerX" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerX, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerY" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerY, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="消息弹窗"
Visibility="{Binding SelectedNode.IsPauseDialog, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标题" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DialogTitle, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="消息内容" />
<TextBox
MinHeight="68"
Margin="0,0,0,8"
Padding="8,6"
AcceptsReturn="True"
BorderBrush="#CFCFCF"
BorderThickness="1"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding SelectedNode.DialogMessage, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="等待参数"
Visibility="{Binding SelectedNode.IsWaitDelay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="10,8,10,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="延时 (ms)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DelayMilliseconds, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
<Border
Padding="12"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6"
Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="未选择节点"
TextWrapping="Wrap" />
<TextBlock
Margin="0,6,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="从左侧树中选择一个节点后,这里会显示可编辑的参数。"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
<Rectangle
Grid.Column="3"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<Grid Grid.Column="4">
<views:PipelineEditorView
x:Name="InspectionModulePipelineEditor"
Visibility="{Binding EditorVisibility}" />
<Border
x:Name="InspectionModulePipelineEmptyState"
Margin="12"
Padding="16"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6"
Visibility="{Binding EmptyStateVisibility}">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="未选择检测模块"
TextWrapping="Wrap" />
<TextBlock
Margin="0,6,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="请选择一个检测模块节点后,在这里拖拽算子并配置参数。"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
</UserControl>
</UserControl>
+81 -3
View File
@@ -1,16 +1,94 @@
using Prism.Ioc;
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services;
using XplorePlane.ViewModels.Cnc;
namespace XplorePlane.Views.Cnc
{
/// <summary>
/// CNC 编辑器主页面视图(MVVM 模式,逻辑在 ViewModel 中)
/// CNC editor main page view (MVVM pattern, logic in ViewModel)
/// CNC editor main page view.
/// </summary>
public partial class CncPageView : UserControl
{
private CncInspectionModulePipelineViewModel _inspectionModulePipelineViewModel;
public CncPageView()
{
InitializeComponent();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
CncEditorViewModel editorViewModel = DataContext as CncEditorViewModel;
if (editorViewModel == null)
{
try
{
editorViewModel = ContainerLocator.Current?.Resolve<CncEditorViewModel>();
DataContext = editorViewModel;
}
catch (Exception)
{
// keep existing DataContext if resolution fails
}
}
if (editorViewModel == null || _inspectionModulePipelineViewModel != null)
return;
try
{
var imageProcessingService = ContainerLocator.Current.Resolve<IImageProcessingService>();
var persistenceService = ContainerLocator.Current.Resolve<IPipelinePersistenceService>();
var logger = ContainerLocator.Current.Resolve<ILoggerService>();
_inspectionModulePipelineViewModel = new CncInspectionModulePipelineViewModel(
editorViewModel,
imageProcessingService,
persistenceService,
logger);
InspectionModulePipelineEditor.DataContext = _inspectionModulePipelineViewModel;
InspectionModulePipelineEmptyState.DataContext = _inspectionModulePipelineViewModel;
}
catch (Exception)
{
// keep page usable even if pipeline editor host setup fails
}
}
private void CncTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (DataContext is CncEditorViewModel viewModel)
{
viewModel.SelectedNode = e.NewValue as CncNodeViewModel;
}
}
}
}
public class NullToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var invert = string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase);
var isVisible = value != null;
if (invert)
{
isVisible = !isVisible;
}
return isVisible ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}
@@ -11,25 +11,23 @@
<UserControl.Resources>
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#cdcbcb" />
<SolidColorBrush x:Key="PanelBorder" Color="#CDCBCB" />
<SolidColorBrush x:Key="SeparatorBrush" Color="#E0E0E0" />
<SolidColorBrush x:Key="AccentBlue" Color="#E3F0FF" />
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<!-- 节点项样式 -->
<Style x:Key="PipelineNodeItemStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<!-- 工具栏按钮样式 -->
<Style x:Key="ToolbarBtn" TargetType="Button">
<Setter Property="Width" Value="28" />
<Setter Property="Width" Value="52" />
<Setter Property="Height" Value="28" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="#cdcbcb" />
<Setter Property="BorderBrush" Value="#CDCBCB" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="FontSize" Value="11" />
@@ -44,21 +42,13 @@
CornerRadius="4">
<Grid>
<Grid.RowDefinitions>
<!-- Row 0: 工具栏 -->
<RowDefinition Height="Auto" />
<!-- Row 2: 流水线节点列表 -->
<RowDefinition Height="2*" MinHeight="180" />
<!-- Row 3: 分隔线 -->
<RowDefinition Height="Auto" />
<!-- Row 4: 参数面板 -->
<RowDefinition Height="2*" MinHeight="80" />
<!-- Row 5: 状态栏 -->
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 标题栏:流水线名称 + 设备选择 -->
<!-- 工具栏 -->
<Border
Grid.Row="0"
Padding="6,4"
@@ -76,50 +66,23 @@
Command="{Binding SavePipelineCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存流水线" />
ToolTip="保存当前检测模块流水线" />
<Button
Width="43"
Width="60"
Command="{Binding SaveAsPipelineCommand}"
Content="另存为"
Style="{StaticResource ToolbarBtn}"
ToolTip="另存为" />
ToolTip="导出当前检测模块流水线" />
<Button
Width="43"
Width="52"
Command="{Binding LoadPipelineCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载流水线" />
<!--
<Button
Width="64"
Command="{Binding LoadImageCommand}"
Content="加载图像"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载输入图像" />
-->
<Button
Command="{Binding ExecutePipelineCommand}"
Content="▶"
Style="{StaticResource ToolbarBtn}"
ToolTip="执行流水线" />
<Button
Command="{Binding CancelExecutionCommand}"
Content="■"
Style="{StaticResource ToolbarBtn}"
ToolTip="取消执行" />
<Button
Command="{Binding DeletePipelineCommand}"
Content="🗑"
Style="{StaticResource ToolbarBtn}"
ToolTip="工具箱" />
ToolTip="流水线模板加载到当前检测模块" />
</StackPanel>
</Grid>
</Border>
<!-- 流水线节点列表(拖拽目标) -->
<ListBox
x:Name="PipelineListBox"
Grid.Row="1"
@@ -138,7 +101,6 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- 连接线列:上半段 + 下半段 -->
<Line
x:Name="TopLine"
HorizontalAlignment="Center"
@@ -159,7 +121,6 @@
Y1="0"
Y2="14" />
<!-- 算子图标 -->
<Border
Grid.Column="0"
Width="28"
@@ -177,7 +138,6 @@
Text="{Binding IconPath}" />
</Border>
<!-- 算子名称 -->
<TextBlock
Grid.Column="1"
Margin="6,0,0,0"
@@ -186,7 +146,6 @@
FontSize="12"
Text="{Binding DisplayName}" />
<!-- 操作按钮:上移 / 下移 / 删除(悬停显示) -->
<StackPanel
x:Name="NodeActions"
Grid.Column="2"
@@ -199,11 +158,11 @@
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#cdcbcb"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="上移" />
@@ -212,11 +171,11 @@
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#cdcbcb"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="下移" />
@@ -229,7 +188,7 @@
BorderThickness="1"
Command="{Binding DataContext.RemoveOperatorCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="删除" />
@@ -246,13 +205,12 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- 分隔线 -->
<Rectangle
Grid.Row="2"
Height="1"
Fill="{StaticResource SeparatorBrush}" />
<!-- 参数面板 -->
<ScrollViewer
Grid.Row="3"
HorizontalScrollBarVisibility="Disabled"
@@ -283,14 +241,14 @@
<TextBox
Grid.Column="1"
Padding="4,2"
BorderBrush="#cdcbcb"
BorderBrush="#CDCBCB"
BorderThickness="1"
FontFamily="Microsoft YaHei UI"
FontSize="11"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="#cdcbcb" />
<Setter Property="BorderBrush" Value="#CDCBCB" />
<Setter Property="Background" Value="White" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsValueValid}" Value="False">
@@ -308,7 +266,6 @@
</StackPanel>
</ScrollViewer>
<!-- 状态栏 -->
<Border
Grid.Row="4"
Padding="6,4"
@@ -1,5 +1,5 @@
using System;
using Prism.Ioc;
using System;
using System.Windows;
using System.Windows.Controls;
using XP.Common.Logging.Interfaces;
@@ -23,7 +23,7 @@ namespace XplorePlane.Views
private void OnLoaded(object sender, RoutedEventArgs e)
{
if (DataContext is not PipelineEditorViewModel)
if (DataContext is not IPipelineEditorHostViewModel)
{
try
{
@@ -46,36 +46,35 @@ namespace XplorePlane.Views
private void OnDragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
e.Effects = DragDropEffects.Copy;
else
e.Effects = DragDropEffects.None;
e.Effects = e.Data.GetDataPresent(OperatorToolboxView.DragFormat)
? DragDropEffects.Copy
: DragDropEffects.None;
e.Handled = true;
}
private void OnOperatorDropped(object sender, DragEventArgs e)
{
if (DataContext is not PipelineEditorViewModel vm)
if (DataContext is not IPipelineEditorHostViewModel vm)
{
_logger?.Warn("Drop 事件触发但 DataContext 不是 PipelineEditorViewModel");
_logger?.Warn("Drop 事件触发但 DataContext 不是流水线宿主 ViewModel");
return;
}
if (!e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
{
_logger?.Warn("Drop 事件触发但数据中没有 {Format}", OperatorToolboxView.DragFormat);
_logger?.Warn("Drop 事件触发但数据中没有 {Format}", OperatorToolboxView.DragFormat);
return;
}
var operatorKey = e.Data.GetData(OperatorToolboxView.DragFormat) as string;
if (string.IsNullOrEmpty(operatorKey))
if (string.IsNullOrWhiteSpace(operatorKey))
{
_logger?.Warn("Drop 事件触发但 OperatorKey 为空");
_logger?.Warn("Drop 事件触发但 OperatorKey 为空");
return;
}
_logger?.Info("算子已放入流水线:{OperatorKey}VM HashCode={Hash}当前节点数(执行前)={Count}",
operatorKey, vm.GetHashCode(), vm.PipelineNodes.Count);
_logger?.Info("算子已放入流水线:{OperatorKey},当前节点数(执行前)={Count}",
operatorKey, vm.PipelineNodes.Count);
vm.AddOperatorCommand.Execute(operatorKey);
_logger?.Info("AddOperator 执行后节点数={Count}PipelineListBox.Items.Count={ItemsCount}",
vm.PipelineNodes.Count, PipelineListBox.Items.Count);
+4 -5
View File
@@ -1,10 +1,9 @@
<UserControl
<UserControl
x:Class="XplorePlane.Views.ImagePanelView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:XplorePlane.Views"
d:DesignHeight="400"
d:DesignWidth="250"
mc:Ignorable="d">
@@ -14,9 +13,9 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,0,0,1">
<TextBlock Margin="8,4" HorizontalAlignment="Left" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="#333333" Text="图像" />
<TextBlock Margin="4,2" HorizontalAlignment="Left" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="#333333" Text="图像处理" />
</Border>
<views:PipelineEditorView Grid.Row="1" />
<ContentControl Grid.Row="1" Content="{Binding ImagePanelContent}" />
</Grid>
</UserControl>
+31 -19
View File
@@ -16,7 +16,7 @@
Height="1040"
d:DesignWidth="1580"
Background="#F5F5F5"
Icon="pack://application:,,,/XplorerPlane.ico"
Icon="pack://application:,,,/XplorePlane;component/XplorerPlane.ico"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.Resources>
@@ -74,7 +74,7 @@
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="新建CNC"
Command="{Binding Path=SetStyle.Command}"
Command="{Binding NewCncProgramCommand}"
Size="Medium"
SmallImage="/Assets/Icons/new-doc.png"
Text="新建CNC" />
@@ -82,11 +82,12 @@
telerik:ScreenTip.Description="保存当前X射线实时图像"
telerik:ScreenTip.Title="保存图像"
Size="Medium"
Command="{Binding SaveCncProgramCommand}"
SmallImage="/Assets/Icons/save.png"
Text="保存" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="另存为"
Command="{Binding OpenFileCommand}"
Command="{Binding SaveCncProgramCommand}"
Size="Medium"
SmallImage="/Assets/Icons/saveas.png"
Text="另存为" />
@@ -94,7 +95,7 @@
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="加载CNC"
Command="{Binding OpenFileCommand}"
Command="{Binding LoadCncProgramCommand}"
Size="Large"
SmallImage="/Assets/Icons/open.png"
Text="加载CNC" />
@@ -284,49 +285,48 @@
Command="{Binding OpenCncEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/cnc.png"
Text="CNC 编辑" />
Text="CNC 编辑" />
<!-- 矩阵编排入口按钮 -->
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开矩阵编排窗口,配置多工件阵列检测方案"
telerik:ScreenTip.Title="矩阵编排"
Command="{Binding OpenMatrixEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
<!-- CNC 节点快捷工具 -->
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="参考点"
Size="Medium"
Command="{Binding InsertReferencePointCommand}"
SmallImage="/Assets/Icons/reference.png"
Text="参考点" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="添加位置"
Size="Medium"
Command="{Binding InsertSavePositionCommand}"
SmallImage="/Assets/Icons/add-pos.png"
Text="添加位置" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="完成"
Size="Medium"
Command="{Binding InsertCompleteProgramCommand}"
SmallImage="/Assets/Icons/finish.png"
Text="完成" />
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="标记"
telerik:ScreenTip.Title="检测标记"
Size="Medium"
Command="{Binding InsertInspectionMarkerCommand}"
SmallImage="/Assets/Icons/mark.png"
Text="标记" />
Text="检测标记" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="模块"
Size="Medium"
Command="{Binding InsertInspectionModuleCommand}"
SmallImage="/Assets/Icons/Module.png"
Text="检测模块" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="全部保存"
Size="Medium"
Command="{Binding SaveCncProgramCommand}"
SmallImage="/Assets/Icons/saveall.png"
Text="全部保存" />
</StackPanel>
@@ -334,14 +334,26 @@
<telerik:RadRibbonButton
telerik:ScreenTip.Title="消息"
Size="Medium"
Command="{Binding InsertPauseDialogCommand}"
SmallImage="/Assets/Icons/message.png"
Text="消息弹窗" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="等待"
Size="Medium"
Command="{Binding InsertWaitDelayCommand}"
SmallImage="/Assets/Icons/wait.png"
Text="插入等待" />
</StackPanel>
<!-- 矩阵编排入口按钮 -->
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开矩阵编排窗口,配置多工件阵列检测方案"
telerik:ScreenTip.Title="矩阵编排"
Command="{Binding OpenMatrixEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="高级模块" IsEnabled="{Binding Path=CellsGroup.IsEnabled}">
@@ -388,7 +400,7 @@
Size="Large"
SmallImage="/Assets/Icons/spiral.png" />
</telerik:RadRibbonGroup>
<!--
<!--
<telerik:RadRibbonGroup Header="图像处理">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
@@ -458,9 +470,9 @@
Margin="0">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="NavColumn" Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="350" />
<ColumnDefinition Width="350" />
<ColumnDefinition Width="{Binding ViewportPanelWidth}" />
<ColumnDefinition Width="{Binding ImagePanelWidth}" />
<ColumnDefinition Width="300" />
</Grid.ColumnDefinitions>
<!-- 左侧: 计划导航 (默认隐藏,点击CNC AccountingNumberFormatButton显示) -->
@@ -19,7 +19,7 @@
<!-- 标题栏 -->
<Border Grid.Row="0" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,0,0,1">
<TextBlock Margin="8,4" HorizontalAlignment="Left" VerticalAlignment="Center"
<TextBlock Margin="4,2" HorizontalAlignment="Left" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="#333333" Text="实时图像" />
</Border>
@@ -30,7 +30,7 @@
<!-- 图像信息栏 -->
<Border Grid.Row="2" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,1,0,0">
<TextBlock Margin="8,2" FontSize="11" Foreground="#666666"
<TextBlock Margin="4,2" FontSize="11" Foreground="#666666"
Text="{Binding ImageInfo}" />
</Border>
</Grid>
+3
View File
@@ -146,6 +146,9 @@
<Link>Libs\Hardware\zh-TW\%(Filename)%(Extension)</Link>
</None>
<Compile Remove="Views\Main\MainWindowB.xaml.cs" />
<Content Include="XplorerPlane.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Resource Include="XplorerPlane.ico" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 92 KiB