456 lines
19 KiB
C#
456 lines
19 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
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;
|
||
|
||
namespace XplorePlane.Services.Cnc
|
||
{
|
||
/// <summary>
|
||
/// CNC 程序管理服务实现。
|
||
/// 负责程序创建、节点编辑(插入/删除/移动 + 自动重编号)、JSON 序列化/反序列化和 .xp 文件读写。
|
||
/// CNC program management service implementation.
|
||
/// Handles program creation, node editing (insert/remove/move + auto-renumber), JSON serialization and .xp file I/O.
|
||
/// </summary>
|
||
public class CncProgramService : ICncProgramService
|
||
{
|
||
private readonly IAppStateService _appStateService;
|
||
private readonly IRaySourceService _raySourceService;
|
||
private readonly ILoggerService _logger;
|
||
|
||
// ── 序列化配置 | Serialization options ──
|
||
private static readonly JsonSerializerOptions CncJsonOptions = new()
|
||
{
|
||
WriteIndented = true,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||
Converters = { new JsonStringEnumConverter() }
|
||
};
|
||
|
||
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");
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public CncProgram CreateProgram(string name)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
throw new ArgumentException("程序名称不能为空 | Program name cannot be empty", nameof(name));
|
||
|
||
var now = DateTime.UtcNow;
|
||
var program = new CncProgram(
|
||
Id: Guid.NewGuid(),
|
||
Name: name,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
Nodes: Array.Empty<CncNode>());
|
||
|
||
_logger.Info("已创建 CNC 程序 | Created CNC program: {ProgramName}, Id={ProgramId}", name, program.Id);
|
||
return program;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public CncNode CreateNode(CncNodeType type)
|
||
{
|
||
var id = Guid.NewGuid();
|
||
const int defaultIndex = 0;
|
||
|
||
CncNode node = type switch
|
||
{
|
||
// 参考点:捕获 6 轴位置 | Reference point: capture 6-axis position
|
||
CncNodeType.ReferencePoint => CreateReferencePointNode(id, defaultIndex),
|
||
|
||
// 保存节点(含图像):捕获全部设备状态 | Save node with image: capture all device states
|
||
CncNodeType.SaveNodeWithImage => CreateSaveNodeWithImageNode(id, defaultIndex),
|
||
|
||
// 保存节点(不含图像):捕获全部设备状态 | Save node: capture all device states
|
||
CncNodeType.SaveNode => CreateSaveNodeNode(id, defaultIndex),
|
||
|
||
// 保存位置:仅捕获运动状态 | Save position: capture motion state only
|
||
CncNodeType.SavePosition => CreateSavePositionNode(id, defaultIndex),
|
||
|
||
// 检测模块:空 Pipeline | Inspection module: empty pipeline
|
||
CncNodeType.InspectionModule => new InspectionModuleNode(
|
||
id, defaultIndex, $"检测模块_{defaultIndex}",
|
||
Pipeline: new PipelineModel()),
|
||
|
||
// 检测标记:默认标记值 | Inspection marker: default marker values
|
||
CncNodeType.InspectionMarker => new InspectionMarkerNode(
|
||
id, defaultIndex, $"检测标记_{defaultIndex}",
|
||
MarkerType: "Default",
|
||
MarkerX: 0.0, MarkerY: 0.0),
|
||
|
||
// 停顿对话框:默认标题/消息 | Pause dialog: default title/message
|
||
CncNodeType.PauseDialog => new PauseDialogNode(
|
||
id, defaultIndex, $"停顿_{defaultIndex}",
|
||
DialogTitle: "暂停",
|
||
DialogMessage: "请确认后继续"),
|
||
|
||
// 等待延时:默认 1000ms | Wait delay: default 1000ms
|
||
CncNodeType.WaitDelay => new WaitDelayNode(
|
||
id, defaultIndex, $"延时_{defaultIndex}",
|
||
DelayMilliseconds: 1000),
|
||
|
||
// 完成程序:默认名称 | Complete program: default name
|
||
CncNodeType.CompleteProgram => new CompleteProgramNode(
|
||
id, defaultIndex, "完成程序"),
|
||
|
||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, $"不支持的节点类型 | Unsupported node type: {type}")
|
||
};
|
||
|
||
_logger.Info("已创建节点 | Created node: Type={NodeType}, Id={NodeId}", type, id);
|
||
return node;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(program);
|
||
ArgumentNullException.ThrowIfNull(node);
|
||
|
||
// 防止重复插入 CompleteProgram | Prevent duplicate CompleteProgram insertion
|
||
if (node.NodeType == CncNodeType.CompleteProgram &&
|
||
program.Nodes.Any(n => n.NodeType == CncNodeType.CompleteProgram))
|
||
{
|
||
throw new InvalidOperationException(
|
||
"CNC 程序中已存在完成程序节点,不允许重复插入 | A CompleteProgram node already exists, duplicate insertion is not allowed");
|
||
}
|
||
|
||
var nodes = new List<CncNode>(program.Nodes);
|
||
|
||
// afterIndex = -1 表示插入到开头 | afterIndex = -1 means insert at the beginning
|
||
int insertAt = afterIndex + 1;
|
||
if (insertAt < 0) insertAt = 0;
|
||
if (insertAt > nodes.Count) insertAt = nodes.Count;
|
||
|
||
nodes.Insert(insertAt, node);
|
||
|
||
var updated = program with
|
||
{
|
||
Nodes = RenumberNodes(nodes),
|
||
UpdatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
_logger.Info("已插入节点 | Inserted node: Type={NodeType}, AfterIndex={AfterIndex}, Program={ProgramName}",
|
||
node.NodeType, afterIndex, program.Name);
|
||
return updated;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public CncProgram RemoveNode(CncProgram program, int index)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(program);
|
||
|
||
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);
|
||
nodes.RemoveAt(index);
|
||
|
||
var updated = program with
|
||
{
|
||
Nodes = RenumberNodes(nodes),
|
||
UpdatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
_logger.Info("已移除节点 | Removed node: Index={Index}, Program={ProgramName}",
|
||
index, program.Name);
|
||
return updated;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(program);
|
||
|
||
if (oldIndex < 0 || oldIndex >= program.Nodes.Count)
|
||
throw new ArgumentOutOfRangeException(nameof(oldIndex),
|
||
$"旧索引超出范围 | Old index out of range: {oldIndex}, Count={program.Nodes.Count}");
|
||
|
||
if (newIndex < 0 || newIndex >= program.Nodes.Count)
|
||
throw new ArgumentOutOfRangeException(nameof(newIndex),
|
||
$"新索引超出范围 | New index out of range: {newIndex}, Count={program.Nodes.Count}");
|
||
|
||
var nodes = new List<CncNode>(program.Nodes);
|
||
var node = nodes[oldIndex];
|
||
nodes.RemoveAt(oldIndex);
|
||
nodes.Insert(newIndex, node);
|
||
|
||
var updated = program with
|
||
{
|
||
Nodes = RenumberNodes(nodes),
|
||
UpdatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
_logger.Info("已移动节点 | Moved node: OldIndex={OldIndex} -> NewIndex={NewIndex}, Program={ProgramName}",
|
||
oldIndex, newIndex, program.Name);
|
||
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)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(program);
|
||
ArgumentNullException.ThrowIfNull(filePath);
|
||
|
||
// 路径安全性验证:检查路径遍历 | Path safety: check for path traversal
|
||
if (filePath.Contains("..", StringComparison.Ordinal))
|
||
throw new UnauthorizedAccessException(
|
||
$"文件路径包含不安全的路径遍历字符 | File path contains unsafe path traversal characters: {filePath}");
|
||
|
||
var json = Serialize(program);
|
||
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
|
||
|
||
_logger.Info("已保存 CNC 程序到文件 | Saved CNC program to file: {FilePath}, Program={ProgramName}",
|
||
filePath, program.Name);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<CncProgram> LoadAsync(string filePath)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(filePath);
|
||
|
||
// 路径安全性验证 | Path safety check
|
||
if (filePath.Contains("..", StringComparison.Ordinal))
|
||
throw new UnauthorizedAccessException(
|
||
$"文件路径包含不安全的路径遍历字符 | File path contains unsafe path traversal characters: {filePath}");
|
||
|
||
if (!File.Exists(filePath))
|
||
throw new FileNotFoundException(
|
||
$"CNC 程序文件不存在 | CNC program file not found: {filePath}", filePath);
|
||
|
||
string json;
|
||
try
|
||
{
|
||
json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex) when (ex is not FileNotFoundException)
|
||
{
|
||
throw new InvalidDataException(
|
||
$"无法读取 CNC 程序文件 | Cannot read CNC program file: {filePath}", ex);
|
||
}
|
||
|
||
var program = Deserialize(json);
|
||
|
||
_logger.Info("已加载 CNC 程序 | Loaded CNC program: {FilePath}, Program={ProgramName}, Nodes={NodeCount}",
|
||
filePath, program.Name, program.Nodes.Count);
|
||
return program;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string Serialize(CncProgram program)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(program);
|
||
return JsonSerializer.Serialize(program, CncJsonOptions);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public CncProgram Deserialize(string json)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(json))
|
||
throw new InvalidDataException("JSON 字符串为空 | JSON string is empty");
|
||
|
||
CncProgram program;
|
||
try
|
||
{
|
||
program = JsonSerializer.Deserialize<CncProgram>(json, CncJsonOptions);
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
throw new InvalidDataException(
|
||
$"CNC 程序 JSON 格式无效 | Invalid CNC program JSON format: {ex.Message}", ex);
|
||
}
|
||
|
||
if (program is null)
|
||
throw new InvalidDataException("反序列化结果为 null | Deserialization result is null");
|
||
|
||
if (string.IsNullOrWhiteSpace(program.Name))
|
||
throw new InvalidDataException("CNC 程序数据不完整,缺少名称 | CNC program data incomplete, missing Name");
|
||
|
||
if (program.Nodes is null)
|
||
throw new InvalidDataException("CNC 程序数据不完整,缺少节点列表 | CNC program data incomplete, missing Nodes");
|
||
|
||
// 对节点应用值域校验(如 WaitDelay 截断)| Apply value range validation to nodes
|
||
var validatedNodes = ValidateNodes(program.Nodes);
|
||
return program with { Nodes = validatedNodes };
|
||
}
|
||
|
||
// ── 常量 | Constants ──
|
||
|
||
/// <summary>等待延时最小值(毫秒)| Wait delay minimum (ms)</summary>
|
||
private const int WaitDelayMin = 0;
|
||
|
||
/// <summary>等待延时最大值(毫秒)| Wait delay maximum (ms)</summary>
|
||
private const int WaitDelayMax = 300000;
|
||
|
||
// ── 内部辅助方法 | Internal helper methods ──
|
||
|
||
/// <summary>
|
||
/// 将等待延时值截断到 [0, 300000] 范围 | Clamp wait delay value to [0, 300000] range
|
||
/// </summary>
|
||
internal static int ClampWaitDelay(int value) =>
|
||
Math.Clamp(value, WaitDelayMin, WaitDelayMax);
|
||
|
||
/// <summary>
|
||
/// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断)
|
||
/// Apply value range validation to deserialized nodes (e.g. WaitDelay clamping)
|
||
/// </summary>
|
||
private static IReadOnlyList<CncNode> ValidateNodes(IReadOnlyList<CncNode> nodes)
|
||
{
|
||
var result = new List<CncNode>(nodes.Count);
|
||
foreach (var node in nodes)
|
||
{
|
||
if (node is WaitDelayNode wdn)
|
||
{
|
||
var clamped = ClampWaitDelay(wdn.DelayMilliseconds);
|
||
result.Add(clamped != wdn.DelayMilliseconds ? wdn with { DelayMilliseconds = clamped } : wdn);
|
||
}
|
||
else
|
||
{
|
||
result.Add(node);
|
||
}
|
||
}
|
||
return result.AsReadOnly();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重新编号节点索引为连续序列 0, 1, 2, ..., N-1(record 不可变,使用 with 语法)
|
||
/// Renumber node indices to consecutive sequence 0, 1, 2, ..., N-1 (records are immutable, use with syntax)
|
||
/// </summary>
|
||
private static IReadOnlyList<CncNode> RenumberNodes(List<CncNode> nodes)
|
||
{
|
||
var result = new List<CncNode>(nodes.Count);
|
||
int referencePointNumber = 0;
|
||
int savePositionNumber = 0;
|
||
int inspectionModuleNumber = 0;
|
||
|
||
for (int i = 0; i < nodes.Count; i++)
|
||
{
|
||
var indexedNode = nodes[i] with { Index = i };
|
||
result.Add(indexedNode switch
|
||
{
|
||
ReferencePointNode referencePointNode => referencePointNode with { Name = $"\u53C2\u8003\u70B9_{referencePointNumber++}" },
|
||
SavePositionNode savePositionNode => savePositionNode with { Name = $"\u4FDD\u5B58\u4F4D\u7F6E_{savePositionNumber++}" },
|
||
InspectionModuleNode inspectionModuleNode => inspectionModuleNode with { Name = $"\u68C0\u6D4B\u6A21\u5757_{inspectionModuleNumber++}" },
|
||
_ => indexedNode
|
||
});
|
||
}
|
||
return result.AsReadOnly();
|
||
}
|
||
|
||
/// <summary>创建参考点节点 | Create reference point node</summary>
|
||
private ReferencePointNode CreateReferencePointNode(Guid id, int index)
|
||
{
|
||
var motion = _appStateService.MotionState;
|
||
var raySource = _appStateService.RaySourceState;
|
||
return new ReferencePointNode(
|
||
id, index, $"参考点_{index}",
|
||
StageX: motion.StageX,
|
||
StageY: motion.StageY,
|
||
SourceZ: motion.SourceZ,
|
||
DetectorZ: motion.DetectorZ,
|
||
DetectorSwing: motion.DetectorSwing,
|
||
FDD: motion.FDD,
|
||
IsRayOn: raySource.IsOn,
|
||
Voltage: raySource.Voltage,
|
||
Current: TryReadCurrent(),
|
||
StageRotation: motion.StageRotation,
|
||
FixtureRotation: motion.FixtureRotation,
|
||
FOD: motion.FOD,
|
||
Magnification: motion.Magnification);
|
||
}
|
||
|
||
/// <summary>创建保存节点(含图像)| Create save node with image</summary>
|
||
private SaveNodeWithImageNode CreateSaveNodeWithImageNode(Guid id, int index)
|
||
{
|
||
return new SaveNodeWithImageNode(
|
||
id, index, $"保存节点_图像_{index}",
|
||
MotionState: _appStateService.MotionState,
|
||
RaySourceState: _appStateService.RaySourceState,
|
||
DetectorState: _appStateService.DetectorState,
|
||
ImageFileName: "");
|
||
}
|
||
|
||
/// <summary>创建保存节点(不含图像)| Create save node without image</summary>
|
||
private SaveNodeNode CreateSaveNodeNode(Guid id, int index)
|
||
{
|
||
return new SaveNodeNode(
|
||
id, index, $"保存节点_{index}",
|
||
MotionState: _appStateService.MotionState,
|
||
RaySourceState: _appStateService.RaySourceState,
|
||
DetectorState: _appStateService.DetectorState);
|
||
}
|
||
|
||
/// <summary>创建保存位置节点 | Create save position node</summary>
|
||
private SavePositionNode CreateSavePositionNode(Guid id, int index)
|
||
{
|
||
return new SavePositionNode(
|
||
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;
|
||
}
|
||
}
|
||
}
|