diff --git a/README.md b/README.md index ca86f50..6d8de7a 100644 --- a/README.md +++ b/README.md @@ -106,5 +106,5 @@ dotnet build XplorePlane.sln -c Release - [x] 相机实时影像集成(连接、采集、Live View、像素坐标显示) - [x] 相机参数设置对话框(曝光、增益、分辨率、像素格式) - [x] 主界面硬件栏相机设置按钮 -- [ ] 打通与硬件层的调用流程 -- [ ] 打通与图像层的调用流程 +- [x] 打通与硬件层的调用流程 +- [x] 打通与图像层的调用流程 diff --git a/XplorePlane/Doc/CNC多检测结果归档.md b/XplorePlane/Doc/CNC多检测结果归档.md new file mode 100644 index 0000000..fd167f3 --- /dev/null +++ b/XplorePlane/Doc/CNC多检测结果归档.md @@ -0,0 +1,505 @@ +# CNC 多检测结果归档与报告取数说明 + +## 1. 目标 + +为 CNC 执行结果提供一套适合报告模块直接取数的归档结构。 + +设计目标: + +- 以“一次工件检测实例”作为主归档单位 +- 同时保留到“检测节点级别”的明细 +- 支持保存: + - CNC 程序名 + - 工件号 / 序列号 + - 检测节点信息 + - 节点使用的 Pipeline / 配方快照 + - 原图、节点输入图、节点最终结果图 + - 节点输出的多个数值结果 + - 节点判定和整次实例判定 +- 方便后续报告模块直接读取,不依赖运行时最新配方 + +当前实现采用: + +- SQLite 保存结构化索引数据 +- 文件系统保存图片资产和 `manifest.json` + +--- + +## 2. 总体设计 + +一次检测实例会生成: + +1. 一组数据库记录 +2. 一组文件目录和图像文件 +3. 一份 `manifest.json` 快照文件 + +归档核心对象: + +- `InspectionRunRecord` + - 一次完整检测实例 +- `InspectionNodeResult` + - 一个 CNC 检测节点的结果 +- `InspectionMetricResult` + - 节点输出的数值结果 +- `InspectionAssetRecord` + - 图像或附件索引 +- `PipelineExecutionSnapshot` + - 节点执行时使用的 Pipeline 快照 + +默认图片保留策略: + +- 整次实例原图 +- 每个节点的输入图 +- 每个节点的最终结果图 + +不默认保存每个算法步骤的中间图。 + +--- + +## 3. 文件存储结构 + +### 3.1 根目录 + +默认根目录: + +```text +%AppData%\XplorePlane\InspectionResults +``` + +每次检测实例按日期和 `RunId` 分层: + +```text +Results/{yyyy}/{MM}/{dd}/{RunId}/ +``` + +### 3.2 示例目录 + +假设: + +- `ProgramName = NewCncProgram` +- `WorkpieceId = QFN_1` +- `SerialNumber = SN-001` +- `RunId = 7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` +- 检测节点共 2 个 + +则文件结构大致为: + +```text +%AppData%\XplorePlane\InspectionResults\ +└─ Results\ + └─ 2026\ + └─ 04\ + └─ 21\ + └─ 7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a\ + ├─ manifest.json + ├─ run\ + │ └─ source.bmp + └─ nodes\ + ├─ 001_检测节点1\ + │ ├─ input.bmp + │ └─ result_overlay.bmp + └─ 002_检测节点2\ + └─ result_overlay.bmp +``` + +### 3.3 文件说明 + +- `run/source.bmp` + - 本次工件检测实例的原始输入图 +- `nodes/001_检测节点1/input.bmp` + - 节点 1 输入图 +- `nodes/001_检测节点1/result_overlay.bmp` + - 节点 1 最终结果图 +- `nodes/002_检测节点2/result_overlay.bmp` + - 节点 2 最终结果图 +- `manifest.json` + - 本次检测完整快照,便于离线查看、调试和导出 + +--- + +## 4. 数据库表设计 + +当前实现包含 5 张主表。 + +### 4.1 `inspection_runs` + +用途:保存一次完整检测实例的主记录。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `run_id` | `TEXT` | 主键,检测实例 ID,GUID | +| `program_name` | `TEXT` | CNC 程序名 | +| `workpiece_id` | `TEXT` | 工件号 | +| `serial_number` | `TEXT` | 序列号 | +| `started_at` | `TEXT` | 开始时间,ISO 8601 | +| `completed_at` | `TEXT` | 结束时间,ISO 8601,可空 | +| `overall_pass` | `INTEGER` | 整体判定,`0/1` | +| `source_image_path` | `TEXT` | 原图相对路径 | +| `result_root_path` | `TEXT` | 本次结果包根目录相对路径 | +| `node_count` | `INTEGER` | 节点数量 | + +样例数据: + +| run_id | program_name | workpiece_id | serial_number | started_at | completed_at | overall_pass | source_image_path | result_root_path | node_count | +|---|---|---|---|---|---|---:|---|---|---:| +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `NewCncProgram` | `QFN_1` | `SN-001` | `2026-04-21T10:00:00.0000000Z` | `2026-04-21T10:00:03.2000000Z` | `0` | `Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp` | `Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `2` | + +### 4.2 `inspection_node_results` + +用途:保存一次检测实例中的节点级结果。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `run_id` | `TEXT` | 所属检测实例 ID | +| `node_id` | `TEXT` | 节点 ID,GUID | +| `node_index` | `INTEGER` | 节点序号 | +| `node_name` | `TEXT` | 节点名称 | +| `pipeline_id` | `TEXT` | Pipeline ID,GUID | +| `pipeline_name` | `TEXT` | Pipeline 名称 | +| `pipeline_version_hash` | `TEXT` | Pipeline 快照 hash | +| `node_pass` | `INTEGER` | 节点判定,`0/1` | +| `source_image_path` | `TEXT` | 节点输入图相对路径 | +| `result_image_path` | `TEXT` | 节点结果图相对路径 | +| `status` | `TEXT` | 节点状态:`Succeeded / Failed / PartialSuccess / AssetMissing` | +| `duration_ms` | `INTEGER` | 节点耗时 | + +样例数据: + +| run_id | node_id | node_index | node_name | pipeline_name | pipeline_version_hash | node_pass | source_image_path | result_image_path | status | duration_ms | +|---|---|---:|---|---|---|---:|---|---|---|---:| +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `1` | `检测节点1` | `Recipe-A` | `A1B2C3...` | `1` | `Results/2026/04/21/.../nodes/001_检测节点1/input.bmp` | `Results/2026/04/21/.../nodes/001_检测节点1/result_overlay.bmp` | `Succeeded` | `135` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `2` | `检测节点2` | `Recipe-B` | `D4E5F6...` | `0` | `` | `Results/2026/04/21/.../nodes/002_检测节点2/result_overlay.bmp` | `Failed` | `240` | + +### 4.3 `inspection_metric_results` + +用途:保存节点输出的结构化数值结果。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `run_id` | `TEXT` | 所属检测实例 ID | +| `node_id` | `TEXT` | 所属节点 ID | +| `metric_key` | `TEXT` | 指标 key | +| `metric_name` | `TEXT` | 指标名称 | +| `metric_value` | `REAL` | 指标值 | +| `unit` | `TEXT` | 单位 | +| `lower_limit` | `REAL` | 下限,可空 | +| `upper_limit` | `REAL` | 上限,可空 | +| `is_pass` | `INTEGER` | 单指标判定,`0/1` | +| `display_order` | `INTEGER` | 展示顺序 | + +样例数据: + +| run_id | node_id | metric_key | metric_name | metric_value | unit | lower_limit | upper_limit | is_pass | display_order | +|---|---|---|---|---:|---|---:|---:|---:|---:| +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `bridge.rate` | `Bridge Rate` | `0.12` | `%` | | `0.2` | `1` | `1` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `void.area` | `Void Area` | `5.6` | `px` | | `8` | `1` | `2` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `solder.height` | `Solder Height` | `1.7` | `mm` | `1.8` | | `0` | `1` | + +### 4.4 `inspection_assets` + +用途:保存文件资产索引。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `run_id` | `TEXT` | 所属检测实例 ID | +| `node_id` | `TEXT` | 所属节点 ID,可空 | +| `asset_type` | `TEXT` | 资产类型 | +| `relative_path` | `TEXT` | 相对路径 | +| `file_format` | `TEXT` | 文件格式 | +| `width` | `INTEGER` | 宽度 | +| `height` | `INTEGER` | 高度 | + +约定的 `asset_type`: + +- `RunSourceImage` +- `NodeInputImage` +- `NodeResultImage` + +样例数据: + +| run_id | node_id | asset_type | relative_path | file_format | width | height | +|---|---|---|---|---|---:|---:| +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | | `RunSourceImage` | `Results/2026/04/21/.../run/source.bmp` | `bmp` | `0` | `0` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `NodeInputImage` | `Results/2026/04/21/.../nodes/001_检测节点1/input.bmp` | `bmp` | `0` | `0` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `NodeResultImage` | `Results/2026/04/21/.../nodes/001_检测节点1/result_overlay.bmp` | `bmp` | `0` | `0` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `NodeResultImage` | `Results/2026/04/21/.../nodes/002_检测节点2/result_overlay.bmp` | `bmp` | `0` | `0` | + +### 4.5 `pipeline_execution_snapshots` + +用途:保存节点执行时的 Pipeline 快照,避免后续配方修改影响历史报告。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `run_id` | `TEXT` | 所属检测实例 ID | +| `node_id` | `TEXT` | 所属节点 ID | +| `pipeline_name` | `TEXT` | Pipeline 名称 | +| `pipeline_definition_json` | `TEXT` | Pipeline 序列化 JSON | +| `pipeline_hash` | `TEXT` | Pipeline JSON 的 SHA-256 | + +样例数据: + +| run_id | node_id | pipeline_name | pipeline_hash | +|---|---|---|---| +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `Recipe-A` | `A1B2C3...` | +| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `Recipe-B` | `D4E5F6...` | + +--- + +## 5. `manifest.json` 示例 + +每次 `CompleteRunAsync` 后,会在结果包目录下生成 `manifest.json`。 + +示例: + +```json +{ + "Run": { + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "ProgramName": "NewCncProgram", + "WorkpieceId": "QFN_1", + "SerialNumber": "SN-001", + "StartedAt": "2026-04-21T10:00:00.0000000Z", + "CompletedAt": "2026-04-21T10:00:03.2000000Z", + "OverallPass": false, + "SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp", + "ResultRootPath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeCount": 2 + }, + "Nodes": [ + { + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": "11111111-1111-1111-1111-111111111111", + "NodeIndex": 1, + "NodeName": "检测节点1", + "PipelineName": "Recipe-A", + "PipelineVersionHash": "A1B2C3...", + "NodePass": true, + "SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/nodes/001_检测节点1/input.bmp", + "ResultImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/nodes/001_检测节点1/result_overlay.bmp", + "Status": 0, + "DurationMs": 135 + }, + { + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": "22222222-2222-2222-2222-222222222222", + "NodeIndex": 2, + "NodeName": "检测节点2", + "PipelineName": "Recipe-B", + "PipelineVersionHash": "D4E5F6...", + "NodePass": false, + "SourceImagePath": "", + "ResultImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/nodes/002_检测节点2/result_overlay.bmp", + "Status": 1, + "DurationMs": 240 + } + ], + "Metrics": [ + { + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": "11111111-1111-1111-1111-111111111111", + "MetricKey": "bridge.rate", + "MetricName": "Bridge Rate", + "MetricValue": 0.12, + "Unit": "%", + "LowerLimit": null, + "UpperLimit": 0.2, + "IsPass": true, + "DisplayOrder": 1 + }, + { + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": "22222222-2222-2222-2222-222222222222", + "MetricKey": "solder.height", + "MetricName": "Solder Height", + "MetricValue": 1.7, + "Unit": "mm", + "LowerLimit": 1.8, + "UpperLimit": null, + "IsPass": false, + "DisplayOrder": 1 + } + ] +} +``` + +说明: + +- `manifest.json` 是文件侧的完整快照 +- SQLite 是主索引 +- 报告模块可以优先查库,再按需读取文件 + +--- + +## 6. 报告模块如何取数 + +### 6.1 首页列表 / 历史查询 + +建议通过: + +- `inspection_runs` +- 必要时联查 `inspection_node_results` + +可支持筛选条件: + +- 时间范围 +- 程序名 +- 工件号 +- 序列号 +- Pipeline 名称 + +### 6.2 单份报告生成 + +建议按 `RunId` 调用: + +- `GetRunDetailAsync(runId)` + +得到: + +- `Run` +- `Nodes` +- `Metrics` +- `Assets` +- `PipelineSnapshots` + +即可直接组装报告: + +- 报告首页 + - 工件号、序列号、程序名、开始/结束时间、整体判定 +- 节点章节 + - 节点名称 + - 配方名 + - 结果图 + - 关键指标值 + - 节点判定 +- 追溯信息 + - Pipeline 快照 hash + - 原图路径 + - 结果图路径 + +### 6.3 离线导出 + +若后续需要将单个检测实例导出给第三方或留档,可直接复制: + +- 整个 `Results/{yyyy}/{MM}/{dd}/{RunId}` 目录 + +这样会同时带走: + +- `manifest.json` +- 原图 +- 节点图 + +--- + +## 7. 当前实现接口 + +当前服务接口: + +- `BeginRunAsync(...)` + - 创建实例记录和结果目录 +- `AppendNodeResultAsync(...)` + - 写入节点结果、指标、图片索引、Pipeline 快照 +- `CompleteRunAsync(...)` + - 回填结束时间、整体判定,并写出 `manifest.json` +- `QueryRunsAsync(...)` + - 查询检测实例列表 +- `GetRunDetailAsync(...)` + - 查询单个实例的完整报告数据 + +当前 DI 注册: + +- `IInspectionResultStore -> InspectionResultStore` + +--- + +## 8. 设计约束与说明 + +### 8.1 为什么不直接扩展 `MeasurementDataService` + +因为原有 `MeasurementRecord` 只适合: + +- 单值统计 +- 简单的 pass/fail 汇总 + +它不适合承载: + +- 多节点 +- 多图像 +- 多指标 +- Pipeline 快照 +- 报告导出 + +所以当前设计中: + +- `MeasurementDataService` 继续保留给旧统计用途 +- `InspectionResultStore` 作为报告归档主通道 + +### 8.2 为什么图片不直接进 SQLite + +因为图片数据量大,直接入库会带来: + +- 数据库膨胀 +- 查询性能下降 +- 迁移和备份成本变高 + +因此使用: + +- SQLite 存索引 +- 文件系统存图片 + +这是更适合报告场景的折中方案。 + +### 8.3 为什么要保存 Pipeline 快照 + +因为报告要可追溯。 + +如果只保存 `PipelineName`,后续配方被修改后,历史报告就无法复原当时的真实算法链。 +因此需要保存: + +- `pipeline_definition_json` +- `pipeline_hash` + +--- + +## 9. 后续可扩展方向 + +后续可继续扩展: + +1. 增加 `manifest.json` 中的设备上下文 + - 运动位置 + - 射线源状态 + - 探测器状态 + +2. 增加中间图可选保留策略 + - 默认关闭 + - 调试模式开启 + +3. 增加结果导出包 + - ZIP 打包 + - 单份报告 PDF + +4. 增加报告模板字段映射 + - 将 `MetricKey` 映射到报告模板占位符 + +5. 增加数据清理策略 + - 保留天数 + - 自动清理旧图片 + - 保留数据库索引或同时删除 + +--- + +## 10. 结论 + +当前这套归档设计的核心特点是: + +- 以“检测实例”为主组织数据 +- 以“检测节点”为明细展开 +- 以“结构化指标 + 图片文件 + Pipeline 快照”支撑报告 +- 通过 SQLite 和文件系统混合存储兼顾查询效率和图片落盘 + +对于后续报告模块,这套结构已经可以直接支持: + +- 历史列表查询 +- 单次检测报告生成 +- 结果图展示 +- 节点级指标展示 +- 历史结果可追溯 diff --git a/XplorePlane/Models/PipelineModels.cs b/XplorePlane/Models/PipelineModels.cs index 8b3ad26..0cf474e 100644 --- a/XplorePlane/Models/PipelineModels.cs +++ b/XplorePlane/Models/PipelineModels.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace XplorePlane.Models { - public class PipelineModel + public class PipelineModel //流程图 { public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = string.Empty; @@ -13,7 +13,7 @@ namespace XplorePlane.Models public List Nodes { get; set; } = new(); } - public class PipelineNodeModel + public class PipelineNodeModel //节点 { public Guid Id { get; set; } = Guid.NewGuid(); public string OperatorKey { get; set; } = string.Empty; diff --git a/XplorePlane/Services/Cnc/CncProgramService.cs b/XplorePlane/Services/Cnc/CncProgramService.cs index f4fa478..8765574 100644 --- a/XplorePlane/Services/Cnc/CncProgramService.cs +++ b/XplorePlane/Services/Cnc/CncProgramService.cs @@ -364,9 +364,20 @@ namespace XplorePlane.Services.Cnc private static IReadOnlyList RenumberNodes(List nodes) { var result = new List(nodes.Count); + int referencePointNumber = 0; + int savePositionNumber = 0; + int inspectionModuleNumber = 0; + for (int i = 0; i < nodes.Count; i++) { - result.Add(nodes[i] with { Index = 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(); } @@ -414,7 +425,7 @@ namespace XplorePlane.Services.Cnc private SavePositionNode CreateSavePositionNode(Guid id, int index) { return new SavePositionNode( - id, index, $"保存位置_{index}", + id, index, $"检测位置_{index}", MotionState: _appStateService.MotionState); } private double TryReadCurrent() diff --git a/XplorePlane/Services/Pipeline/PipelineExecutionService.cs b/XplorePlane/Services/Pipeline/PipelineExecutionService.cs index 6f5af09..648c42a 100644 --- a/XplorePlane/Services/Pipeline/PipelineExecutionService.cs +++ b/XplorePlane/Services/Pipeline/PipelineExecutionService.cs @@ -37,11 +37,10 @@ namespace XplorePlane.Services if (enabledNodes.Count == 0) return source; - // 大图像预览缩放 var current = ScaleForPreview(source); - int total = enabledNodes.Count; - for (int step = 0; step < total; step++) + var total = enabledNodes.Count; + for (var step = 0; step < total; step++) { cancellationToken.ThrowIfCancellationRequested(); @@ -53,15 +52,14 @@ namespace XplorePlane.Services if (invalidParameters.Count > 0) { + var invalidParameterText = string.Join("、", invalidParameters); throw new PipelineExecutionException( - $"算子 '{node.DisplayName}' 存在无效参数:{string.Join("、", invalidParameters)}", + $"算子 '{node.DisplayName}' 存在无效参数:{invalidParameterText}", node.Order, node.OperatorKey); } - var parameters = node.Parameters - .Where(p => p.IsValueValid) - .ToDictionary(p => p.Name, p => p.Value); + var parameters = node.Parameters.ToDictionary(p => p.Name, p => p.Value); try { @@ -69,9 +67,12 @@ namespace XplorePlane.Services current, node.OperatorKey, parameters, null, cancellationToken); if (current == null) + { throw new PipelineExecutionException( - $"算子 '{node.OperatorKey}' 返回了空图像", - node.Order, node.OperatorKey); + $"算子 '{node.DisplayName}' 返回了空图像", + node.Order, + node.OperatorKey); + } } catch (OperationCanceledException) { @@ -85,7 +86,9 @@ namespace XplorePlane.Services { throw new PipelineExecutionException( $"算子 '{node.DisplayName}' 执行失败:{ex.Message}", - node.Order, node.OperatorKey, ex); + node.Order, + node.OperatorKey, + ex); } progress?.Report(new PipelineProgress(step + 1, total, node.DisplayName)); @@ -102,7 +105,7 @@ namespace XplorePlane.Services if (source.PixelWidth <= UhdThreshold && source.PixelHeight <= UhdThreshold) return source; - double scale = (double)PreviewMaxHeight / source.PixelHeight; + var scale = (double)PreviewMaxHeight / source.PixelHeight; if (source.PixelWidth * scale > UhdThreshold) scale = (double)UhdThreshold / source.PixelWidth; diff --git a/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs b/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs index 22ac8ce..5ffaa18 100644 --- a/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs +++ b/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs @@ -74,7 +74,7 @@ namespace XplorePlane.Services if (!Directory.Exists(directory)) return Array.Empty(); - var files = Directory.GetFiles(directory, "*.imw"); + var files = Directory.GetFiles(directory, "*.xpm"); var results = new List(); foreach (var file in files) @@ -108,4 +108,4 @@ namespace XplorePlane.Services throw new UnauthorizedAccessException($"不允许路径遍历:{directory}"); } } -} \ No newline at end of file +} diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs index 61a3cdb..60a5c6e 100644 --- a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs @@ -30,10 +30,14 @@ namespace XplorePlane.ViewModels.Cnc private CncProgram _currentProgram; private ObservableCollection _nodes; private ObservableCollection _treeNodes; + private ObservableCollection _programTreeRoots; private CncNodeViewModel _selectedNode; private bool _isModified; private string _programName; + private string _programDisplayName = "新建检测程序.xp"; private Guid? _preferredSelectedNodeId; + private Guid? _pendingInsertAnchorNodeId; + private bool _pendingInsertAfterAnchor; public CncEditorViewModel( ICncProgramService cncProgramService, @@ -48,6 +52,10 @@ namespace XplorePlane.ViewModels.Cnc _nodes = new ObservableCollection(); _treeNodes = new ObservableCollection(); + _programTreeRoots = new ObservableCollection + { + new CncProgramTreeRootViewModel(_programDisplayName, _treeNodes) + }; InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint)); InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage)); @@ -63,6 +71,8 @@ namespace XplorePlane.ViewModels.Cnc .ObservesProperty(() => SelectedNode); MoveNodeUpCommand = new DelegateCommand(ExecuteMoveNodeUp); MoveNodeDownCommand = new DelegateCommand(ExecuteMoveNodeDown); + PrepareInsertAboveCommand = new DelegateCommand(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: false)); + PrepareInsertBelowCommand = new DelegateCommand(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: true)); SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync()); LoadProgramCommand = new DelegateCommand(async () => await ExecuteLoadProgramAsync()); @@ -84,6 +94,12 @@ namespace XplorePlane.ViewModels.Cnc private set => SetProperty(ref _treeNodes, value); } + public ObservableCollection ProgramTreeRoots + { + get => _programTreeRoots; + private set => SetProperty(ref _programTreeRoots, value); + } + public CncNodeViewModel SelectedNode { get => _selectedNode; @@ -110,6 +126,16 @@ namespace XplorePlane.ViewModels.Cnc set => SetProperty(ref _programName, value); } + public string ProgramDisplayName + { + get => _programDisplayName; + private set + { + if (SetProperty(ref _programDisplayName, value) && ProgramTreeRoots?.Count > 0) + ProgramTreeRoots[0].DisplayName = value; + } + } + public DelegateCommand InsertReferencePointCommand { get; } public DelegateCommand InsertSaveNodeWithImageCommand { get; } public DelegateCommand InsertSaveNodeCommand { get; } @@ -122,6 +148,8 @@ namespace XplorePlane.ViewModels.Cnc public DelegateCommand DeleteNodeCommand { get; } public DelegateCommand MoveNodeUpCommand { get; } public DelegateCommand MoveNodeDownCommand { get; } + public DelegateCommand PrepareInsertAboveCommand { get; } + public DelegateCommand PrepareInsertBelowCommand { get; } public DelegateCommand SaveProgramCommand { get; } public DelegateCommand LoadProgramCommand { get; } public DelegateCommand NewProgramCommand { get; } @@ -140,6 +168,7 @@ namespace XplorePlane.ViewModels.Cnc int afterIndex = ResolveInsertAfterIndex(nodeType); _currentProgram = _cncProgramService.InsertNode(_currentProgram, afterIndex, node); _preferredSelectedNodeId = node.Id; + ClearPendingInsertAnchor(); OnProgramEdited(); _logger.Info("Inserted node: Type={NodeType}", nodeType); @@ -157,21 +186,24 @@ namespace XplorePlane.ViewModels.Cnc try { + int deletedIndex = SelectedNode.Index; + if (SelectedNode.IsSavePosition) { var nodes = _currentProgram.Nodes.ToList(); - int startIndex = SelectedNode.Index; + int startIndex = deletedIndex; int endIndex = GetSavePositionBlockEndIndex(startIndex); nodes.RemoveRange(startIndex, endIndex - startIndex + 1); _currentProgram = ReplaceProgramNodes(nodes); } else { - _currentProgram = _cncProgramService.RemoveNode(_currentProgram, SelectedNode.Index); + _currentProgram = _cncProgramService.RemoveNode(_currentProgram, deletedIndex); } OnProgramEdited(); - _logger.Info("Deleted node at index: {Index}", SelectedNode.Index); + ClearPendingInsertAnchorIfMissing(); + _logger.Info("Deleted node at index: {Index}", deletedIndex); } catch (ArgumentOutOfRangeException ex) { @@ -256,6 +288,7 @@ namespace XplorePlane.ViewModels.Cnc return; await _cncProgramService.SaveAsync(_currentProgram, dlg.FileName); + ProgramDisplayName = Path.GetFileName(dlg.FileName); IsModified = false; } catch (Exception ex) @@ -280,7 +313,9 @@ namespace XplorePlane.ViewModels.Cnc _currentProgram = await _cncProgramService.LoadAsync(dlg.FileName); ProgramName = _currentProgram.Name; + ProgramDisplayName = Path.GetFileName(dlg.FileName); IsModified = false; + ClearPendingInsertAnchor(); RefreshNodes(); } catch (Exception ex) @@ -291,10 +326,12 @@ namespace XplorePlane.ViewModels.Cnc private void ExecuteNewProgram() { - var name = string.IsNullOrWhiteSpace(ProgramName) ? "NewCncProgram" : ProgramName; + const string name = "新建检测程序"; _currentProgram = _cncProgramService.CreateProgram(name); ProgramName = _currentProgram.Name; + ProgramDisplayName = FormatProgramDisplayName(_currentProgram.Name); IsModified = false; + ClearPendingInsertAnchor(); RefreshNodes(); } @@ -379,6 +416,8 @@ namespace XplorePlane.ViewModels.Cnc private void RefreshNodes() { + NormalizeDefaultNodeNamesInCurrentProgram(); + var selectedId = _preferredSelectedNodeId ?? SelectedNode?.Id; var expansionState = Nodes.ToDictionary(node => node.Id, node => node.IsExpanded); @@ -417,6 +456,10 @@ namespace XplorePlane.ViewModels.Cnc Nodes = new ObservableCollection(flatNodes); TreeNodes = new ObservableCollection(rootNodes); + ProgramTreeRoots = new ObservableCollection + { + new CncProgramTreeRootViewModel(ProgramDisplayName, TreeNodes) + }; SelectedNode = selectedId.HasValue ? Nodes.FirstOrDefault(node => node.Id == selectedId.Value) ?? Nodes.LastOrDefault() @@ -425,6 +468,31 @@ namespace XplorePlane.ViewModels.Cnc _preferredSelectedNodeId = null; } + private void NormalizeDefaultNodeNamesInCurrentProgram() + { + if (_currentProgram?.Nodes == null || _currentProgram.Nodes.Count == 0) + { + return; + } + + var normalizedNodes = ApplyDefaultNodeNames(_currentProgram.Nodes); + + bool changed = false; + for (int i = 0; i < normalizedNodes.Count; i++) + { + if (!Equals(normalizedNodes[i], _currentProgram.Nodes[i])) + { + changed = true; + break; + } + } + + if (changed) + { + _currentProgram = _currentProgram with { Nodes = normalizedNodes }; + } + } + private int ResolveInsertAfterIndex(CncNodeType nodeType) { if (_currentProgram == null || _currentProgram.Nodes.Count == 0) @@ -432,8 +500,19 @@ namespace XplorePlane.ViewModels.Cnc return -1; } + if (TryResolvePendingInsertAfterIndex(nodeType, out int pendingAfterIndex)) + { + return pendingAfterIndex; + } + if (!IsSavePositionChild(nodeType)) { + int? currentSavePositionIndex = FindOwningSavePositionIndex(SelectedNode?.Index); + if (currentSavePositionIndex.HasValue) + { + return GetSavePositionBlockEndIndex(currentSavePositionIndex.Value); + } + return SelectedNode?.Index ?? _currentProgram.Nodes.Count - 1; } @@ -446,6 +525,75 @@ namespace XplorePlane.ViewModels.Cnc return GetSavePositionBlockEndIndex(savePositionIndex.Value); } + private void SetPendingInsertAnchor(CncNodeViewModel nodeVm, bool insertAfter) + { + if (_currentProgram == null || nodeVm == null) + { + return; + } + + _pendingInsertAnchorNodeId = nodeVm.Id; + _pendingInsertAfterAnchor = insertAfter; + SelectedNode = nodeVm; + } + + private bool TryResolvePendingInsertAfterIndex(CncNodeType nodeType, out int afterIndex) + { + afterIndex = -1; + + if (!_pendingInsertAnchorNodeId.HasValue || _currentProgram == null || IsSavePositionChild(nodeType)) + { + return false; + } + + int anchorIndex = FindNodeIndexById(_pendingInsertAnchorNodeId.Value); + if (anchorIndex < 0) + { + ClearPendingInsertAnchor(); + return false; + } + + afterIndex = _pendingInsertAfterAnchor ? anchorIndex : anchorIndex - 1; + return true; + } + + private int FindNodeIndexById(Guid nodeId) + { + if (_currentProgram?.Nodes == null) + { + return -1; + } + + for (int i = 0; i < _currentProgram.Nodes.Count; i++) + { + if (_currentProgram.Nodes[i].Id == nodeId) + { + return i; + } + } + + return -1; + } + + private void ClearPendingInsertAnchor() + { + _pendingInsertAnchorNodeId = null; + _pendingInsertAfterAnchor = false; + } + + private void ClearPendingInsertAnchorIfMissing() + { + if (!_pendingInsertAnchorNodeId.HasValue) + { + return; + } + + if (FindNodeIndexById(_pendingInsertAnchorNodeId.Value) < 0) + { + ClearPendingInsertAnchor(); + } + } + private void MoveSavePositionChild(CncNodeViewModel nodeVm, bool moveDown) { int? parentIndex = FindOwningSavePositionIndex(nodeVm.Index); @@ -598,10 +746,7 @@ namespace XplorePlane.ViewModels.Cnc private CncProgram ReplaceProgramNodes(List nodes) { - var renumberedNodes = nodes - .Select((node, index) => node with { Index = index }) - .ToList() - .AsReadOnly(); + var renumberedNodes = ApplyDefaultNodeNames(nodes); return _currentProgram with { @@ -610,6 +755,28 @@ namespace XplorePlane.ViewModels.Cnc }; } + private static IReadOnlyList ApplyDefaultNodeNames(IReadOnlyList nodes) + { + var result = new List(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(); + } + private static bool IsSavePositionChild(CncNodeType type) { return type is CncNodeType.InspectionMarker @@ -622,5 +789,11 @@ namespace XplorePlane.ViewModels.Cnc .GetEvent() .Publish(new CncProgramChangedPayload(ProgramName ?? string.Empty, IsModified)); } + + private static string FormatProgramDisplayName(string programName) + { + var name = string.IsNullOrWhiteSpace(programName) ? "新建检测程序" : programName; + return name.EndsWith(".xp", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.xp"; + } } } diff --git a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs index b6dc7d7..d119c0c 100644 --- a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using Prism.Commands; using Prism.Mvvm; using System; @@ -26,6 +26,7 @@ namespace XplorePlane.ViewModels.Cnc private CncNodeViewModel _activeModuleNode; private PipelineNodeViewModel _selectedNode; private string _statusMessage = "请选择检测模块以编辑其流水线。"; + private string _pipelineFileDisplayName = "未命名模块.xpm"; private string _currentFilePath; private bool _isSynchronizing; @@ -44,6 +45,8 @@ namespace XplorePlane.ViewModels.Cnc AddOperatorCommand = new DelegateCommand(AddOperator, _ => HasActiveModule); RemoveOperatorCommand = new DelegateCommand(RemoveOperator); + ReorderOperatorCommand = new DelegateCommand(ReorderOperator); + ToggleOperatorEnabledCommand = new DelegateCommand(ToggleOperatorEnabled); MoveNodeUpCommand = new DelegateCommand(MoveNodeUp); MoveNodeDownCommand = new DelegateCommand(MoveNodeDown); NewPipelineCommand = new DelegateCommand(NewPipeline); @@ -69,6 +72,14 @@ namespace XplorePlane.ViewModels.Cnc private set => SetProperty(ref _statusMessage, value); } + public bool IsStatusError => false; + + public string PipelineFileDisplayName + { + get => _pipelineFileDisplayName; + private set => SetProperty(ref _pipelineFileDisplayName, value); + } + public bool HasActiveModule => _activeModuleNode?.IsInspectionModule == true; public Visibility EditorVisibility => HasActiveModule ? Visibility.Visible : Visibility.Collapsed; @@ -79,6 +90,10 @@ namespace XplorePlane.ViewModels.Cnc public ICommand RemoveOperatorCommand { get; } + public ICommand ReorderOperatorCommand { get; } + + public ICommand ToggleOperatorEnabledCommand { get; } + public ICommand MoveNodeUpCommand { get; } public ICommand MoveNodeDownCommand { get; } @@ -107,6 +122,7 @@ namespace XplorePlane.ViewModels.Cnc _activeModuleNode = null; PipelineNodes.Clear(); SelectedNode = null; + PipelineFileDisplayName = "未命名模块.xpm"; StatusMessage = "请选择检测模块以编辑其流水线。"; RaiseModuleVisibilityChanged(); RaiseCommandCanExecuteChanged(); @@ -114,6 +130,7 @@ namespace XplorePlane.ViewModels.Cnc } _activeModuleNode = selected; + _currentFilePath = null; LoadPipelineModel(_activeModuleNode.Pipeline ?? new PipelineModel { Name = _activeModuleNode.Name @@ -152,13 +169,10 @@ namespace XplorePlane.ViewModels.Cnc if (!HasActiveModule || node == null || !PipelineNodes.Contains(node)) return; + var removedIndex = PipelineNodes.IndexOf(node); PipelineNodes.Remove(node); RenumberNodes(); - - if (SelectedNode == node) - { - SelectedNode = PipelineNodes.LastOrDefault(); - } + SelectNeighborAfterRemoval(removedIndex); PersistActiveModule($"已移除算子:{node.DisplayName}"); } @@ -177,6 +191,26 @@ namespace XplorePlane.ViewModels.Cnc PersistActiveModule($"已上移算子:{node.DisplayName}"); } + private void ReorderOperator(PipelineReorderArgs args) + { + if (!HasActiveModule || args == null) + return; + + var oldIndex = args.OldIndex; + var newIndex = args.NewIndex; + if (oldIndex < 0 || oldIndex >= PipelineNodes.Count) + return; + + if (newIndex < 0 || newIndex >= PipelineNodes.Count || oldIndex == newIndex) + return; + + var node = PipelineNodes[oldIndex]; + PipelineNodes.Move(oldIndex, newIndex); + RenumberNodes(); + SelectedNode = node; + PersistActiveModule($"已调整算子顺序:{node.DisplayName}"); + } + private void MoveNodeDown(PipelineNodeViewModel node) { if (!HasActiveModule || node == null) @@ -191,6 +225,18 @@ namespace XplorePlane.ViewModels.Cnc PersistActiveModule($"已下移算子:{node.DisplayName}"); } + private void ToggleOperatorEnabled(PipelineNodeViewModel node) + { + if (!HasActiveModule || node == null || !PipelineNodes.Contains(node)) + return; + + node.IsEnabled = !node.IsEnabled; + SelectedNode = node; + PersistActiveModule(node.IsEnabled + ? $"已启用算子:{node.DisplayName}" + : $"已停用算子:{node.DisplayName}"); + } + private void NewPipeline() { if (!HasActiveModule) @@ -199,6 +245,7 @@ namespace XplorePlane.ViewModels.Cnc PipelineNodes.Clear(); SelectedNode = null; _currentFilePath = null; + PipelineFileDisplayName = GetActivePipelineFileDisplayName(); PersistActiveModule("已为当前检测模块新建空流水线。"); } @@ -217,7 +264,9 @@ namespace XplorePlane.ViewModels.Cnc var dialog = new SaveFileDialog { - Filter = "图像处理流水线 (*.imw)|*.imw", + Filter = "XP 模块流水线 (*.xpm)|*.xpm", + DefaultExt = ".xpm", + AddExtension = true, FileName = GetActivePipelineName() }; @@ -227,6 +276,7 @@ namespace XplorePlane.ViewModels.Cnc var model = BuildPipelineModel(); await _persistenceService.SaveAsync(model, dialog.FileName); _currentFilePath = dialog.FileName; + PipelineFileDisplayName = FormatPipelinePath(dialog.FileName); StatusMessage = $"已导出模块流水线:{Path.GetFileName(dialog.FileName)}"; } @@ -237,7 +287,8 @@ namespace XplorePlane.ViewModels.Cnc var dialog = new OpenFileDialog { - Filter = "图像处理流水线 (*.imw)|*.imw" + Filter = "XP 模块流水线 (*.xpm)|*.xpm", + DefaultExt = ".xpm" }; if (dialog.ShowDialog() != true) @@ -245,6 +296,7 @@ namespace XplorePlane.ViewModels.Cnc var model = await _persistenceService.LoadAsync(dialog.FileName); _currentFilePath = dialog.FileName; + PipelineFileDisplayName = FormatPipelinePath(dialog.FileName); LoadPipelineModel(model); PersistActiveModule($"已加载模块流水线:{model.Name}"); } @@ -276,6 +328,8 @@ namespace XplorePlane.ViewModels.Cnc } SelectedNode = PipelineNodes.FirstOrDefault(); + if (string.IsNullOrEmpty(_currentFilePath)) + PipelineFileDisplayName = GetActivePipelineFileDisplayName(); StatusMessage = HasActiveModule ? $"正在编辑检测模块:{_activeModuleNode.Name}" : "请选择检测模块以编辑其流水线。"; @@ -351,6 +405,21 @@ namespace XplorePlane.ViewModels.Cnc : _activeModuleNode.Pipeline.Name; } + private string GetActivePipelineFileDisplayName() + { + var pipelineName = GetActivePipelineName(); + return pipelineName.EndsWith(".xpm", StringComparison.OrdinalIgnoreCase) + ? pipelineName + : $"{pipelineName}.xpm"; + } + + private static string FormatPipelinePath(string filePath) + { + return string.IsNullOrWhiteSpace(filePath) + ? "未命名模块.xpm" + : Path.GetFullPath(filePath).Replace('\\', '/'); + } + private void RenumberNodes() { for (var i = 0; i < PipelineNodes.Count; i++) @@ -359,6 +428,20 @@ namespace XplorePlane.ViewModels.Cnc } } + private void SelectNeighborAfterRemoval(int removedIndex) + { + if (PipelineNodes.Count == 0) + { + SelectedNode = null; + return; + } + + var nextIndex = removedIndex < PipelineNodes.Count + ? removedIndex + : PipelineNodes.Count - 1; + SelectedNode = PipelineNodes[nextIndex]; + } + private void RaiseModuleVisibilityChanged() { RaisePropertyChanged(nameof(HasActiveModule)); diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs index 6e00950..0fed114 100644 --- a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs @@ -37,6 +37,8 @@ namespace XplorePlane.ViewModels.Cnc set => UpdateModel(_model with { Name = value ?? string.Empty }); } + public bool IsReadOnlyNodeProperties => IsReferencePoint || IsSavePosition; + public CncNodeType NodeType => _model.NodeType; public string NodeTypeDisplay => NodeType.ToString(); diff --git a/XplorePlane/ViewModels/Cnc/CncProgramTreeRootViewModel.cs b/XplorePlane/ViewModels/Cnc/CncProgramTreeRootViewModel.cs new file mode 100644 index 0000000..2b1e5ea --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/CncProgramTreeRootViewModel.cs @@ -0,0 +1,31 @@ +using Prism.Mvvm; +using System.Collections.ObjectModel; + +namespace XplorePlane.ViewModels.Cnc +{ + public class CncProgramTreeRootViewModel : BindableBase + { + private string _displayName; + private bool _isExpanded = true; + + public CncProgramTreeRootViewModel(string displayName, ObservableCollection children) + { + _displayName = displayName; + Children = children; + } + + public string DisplayName + { + get => _displayName; + set => SetProperty(ref _displayName, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public ObservableCollection Children { get; } + } +} diff --git a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs index 6bdcc74..9883ca8 100644 --- a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs @@ -11,10 +11,18 @@ namespace XplorePlane.ViewModels string StatusMessage { get; } + bool IsStatusError { get; } + + string PipelineFileDisplayName { get; } + ICommand AddOperatorCommand { get; } ICommand RemoveOperatorCommand { get; } + ICommand ReorderOperatorCommand { get; } + + ICommand ToggleOperatorEnabledCommand { get; } + ICommand MoveNodeUpCommand { get; } ICommand MoveNodeDownCommand { get; } diff --git a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs index 618f111..129c0fe 100644 --- a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using Prism.Events; using Prism.Commands; using Prism.Mvvm; @@ -21,6 +21,7 @@ namespace XplorePlane.ViewModels { private const int MaxPipelineLength = 20; private const int DebounceDelayMs = 300; + private const string DefaultPipelineFileDisplayName = "未命名模块.xpm"; private readonly IImageProcessingService _imageProcessingService; private readonly IPipelineExecutionService _executionService; @@ -34,7 +35,9 @@ namespace XplorePlane.ViewModels private string _pipelineName = "新建流水线"; private string _selectedDevice = string.Empty; private bool _isExecuting; + private bool _isStatusError; private string _statusMessage = string.Empty; + private string _pipelineFileDisplayName = DefaultPipelineFileDisplayName; private string _currentFilePath; private CancellationTokenSource _executionCts; @@ -59,6 +62,7 @@ namespace XplorePlane.ViewModels AddOperatorCommand = new DelegateCommand(AddOperator, CanAddOperator); RemoveOperatorCommand = new DelegateCommand(RemoveOperator); ReorderOperatorCommand = new DelegateCommand(ReorderOperator); + ToggleOperatorEnabledCommand = new DelegateCommand(ToggleOperatorEnabled); ExecutePipelineCommand = new DelegateCommand(async () => await ExecutePipelineAsync(), () => !IsExecuting && SourceImage != null); CancelExecutionCommand = new DelegateCommand(CancelExecution, () => IsExecuting); NewPipelineCommand = new DelegateCommand(NewPipeline); @@ -147,11 +151,24 @@ namespace XplorePlane.ViewModels set => SetProperty(ref _statusMessage, value); } + public bool IsStatusError + { + get => _isStatusError; + private set => SetProperty(ref _isStatusError, value); + } + + public string PipelineFileDisplayName + { + get => _pipelineFileDisplayName; + private set => SetProperty(ref _pipelineFileDisplayName, value); + } + // ── Commands ────────────────────────────────────────────────── public DelegateCommand AddOperatorCommand { get; } public DelegateCommand RemoveOperatorCommand { get; } public DelegateCommand ReorderOperatorCommand { get; } + public DelegateCommand ToggleOperatorEnabledCommand { get; } public DelegateCommand ExecutePipelineCommand { get; } public DelegateCommand CancelExecutionCommand { get; } public DelegateCommand NewPipelineCommand { get; } @@ -168,6 +185,8 @@ namespace XplorePlane.ViewModels ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand; ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand; + ICommand IPipelineEditorHostViewModel.ReorderOperatorCommand => ReorderOperatorCommand; + ICommand IPipelineEditorHostViewModel.ToggleOperatorEnabledCommand => ToggleOperatorEnabledCommand; ICommand IPipelineEditorHostViewModel.MoveNodeUpCommand => MoveNodeUpCommand; ICommand IPipelineEditorHostViewModel.MoveNodeDownCommand => MoveNodeDownCommand; ICommand IPipelineEditorHostViewModel.NewPipelineCommand => NewPipelineCommand; @@ -186,7 +205,7 @@ namespace XplorePlane.ViewModels if (string.IsNullOrWhiteSpace(operatorKey)) { - StatusMessage = "算子键不能为空"; + SetInfoStatus("算子键不能为空"); _logger.Warn("AddOperator 失败:operatorKey 为空"); return; } @@ -197,14 +216,14 @@ namespace XplorePlane.ViewModels if (!available.Contains(operatorKey)) { - StatusMessage = $"算子 '{operatorKey}' 未注册"; + SetInfoStatus($"算子 '{operatorKey}' 未注册"); _logger.Warn("AddOperator 失败:算子 {Key} 未注册", operatorKey); return; } if (PipelineNodes.Count >= MaxPipelineLength) { - StatusMessage = $"流水线节点数已达上限({MaxPipelineLength})"; + SetInfoStatus($"流水线节点数已达上限({MaxPipelineLength})"); _logger.Warn("AddOperator 失败:节点数已达上限 {Max}", MaxPipelineLength); return; } @@ -217,9 +236,10 @@ namespace XplorePlane.ViewModels }; LoadNodeParameters(node); PipelineNodes.Add(node); + SelectedNode = node; _logger.Info("节点已添加到 PipelineNodes:{Key} ({DisplayName}),当前节点数={Count}", operatorKey, displayName, PipelineNodes.Count); - StatusMessage = $"已添加算子:{displayName}"; + SetInfoStatus($"已添加算子:{displayName}"); TriggerDebouncedExecution(); } @@ -227,13 +247,12 @@ namespace XplorePlane.ViewModels { if (node == null || !PipelineNodes.Contains(node)) return; + var removedIndex = PipelineNodes.IndexOf(node); PipelineNodes.Remove(node); RenumberNodes(); + SelectNeighborAfterRemoval(removedIndex); - if (SelectedNode == node) - SelectedNode = null; - - StatusMessage = $"已移除算子:{node.DisplayName}"; + SetInfoStatus($"已移除算子:{node.DisplayName}"); TriggerDebouncedExecution(); } @@ -271,6 +290,20 @@ namespace XplorePlane.ViewModels PipelineNodes.RemoveAt(oldIndex); PipelineNodes.Insert(newIndex, node); RenumberNodes(); + SelectedNode = node; + SetInfoStatus($"已调整算子顺序:{node.DisplayName}"); + TriggerDebouncedExecution(); + } + + private void ToggleOperatorEnabled(PipelineNodeViewModel node) + { + if (node == null || !PipelineNodes.Contains(node)) return; + + node.IsEnabled = !node.IsEnabled; + SelectedNode = node; + SetInfoStatus(node.IsEnabled + ? $"已启用算子:{node.DisplayName}" + : $"已停用算子:{node.DisplayName}"); TriggerDebouncedExecution(); } @@ -280,6 +313,20 @@ namespace XplorePlane.ViewModels PipelineNodes[i].Order = i; } + private void SelectNeighborAfterRemoval(int removedIndex) + { + if (PipelineNodes.Count == 0) + { + SelectedNode = null; + return; + } + + var nextIndex = removedIndex < PipelineNodes.Count + ? removedIndex + : PipelineNodes.Count - 1; + SelectedNode = PipelineNodes[nextIndex]; + } + private void LoadNodeParameters(PipelineNodeViewModel node) { var paramDefs = _imageProcessingService.GetProcessorParameters(node.OperatorKey); @@ -297,7 +344,12 @@ namespace XplorePlane.ViewModels vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(ProcessorParameterVM.Value)) + { + if (TryReportInvalidParameters()) + return; + TriggerDebouncedExecution(); + } }; node.Parameters.Add(vm); } @@ -307,36 +359,39 @@ namespace XplorePlane.ViewModels { if (SourceImage == null || IsExecuting) return; + if (TryReportInvalidParameters()) + return; + _executionCts?.Cancel(); _executionCts = new CancellationTokenSource(); var token = _executionCts.Token; IsExecuting = true; - StatusMessage = "正在执行流水线..."; + SetInfoStatus("正在执行流水线..."); try { var progress = new Progress(p => - StatusMessage = $"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})"); + SetInfoStatus($"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})")); var result = await _executionService.ExecutePipelineAsync( PipelineNodes, SourceImage, progress, token); PreviewImage = result; - StatusMessage = "流水线执行完成"; + SetInfoStatus("流水线执行完成"); PublishPipelinePreviewUpdated(result, StatusMessage); } catch (OperationCanceledException) { - StatusMessage = "流水线执行已取消"; + SetInfoStatus("流水线执行已取消"); } catch (PipelineExecutionException ex) { - StatusMessage = $"节点 '{ex.FailedOperatorKey}' 执行失败:{ex.Message}"; + SetErrorStatus($"执行失败:{ex.Message}"); } catch (Exception ex) { - StatusMessage = $"执行错误:{ex.Message}"; + SetErrorStatus($"执行错误:{ex.Message}"); } finally { @@ -344,6 +399,36 @@ namespace XplorePlane.ViewModels } } + private bool TryReportInvalidParameters() + { + var firstInvalidNode = PipelineNodes + .Where(n => n.IsEnabled) + .OrderBy(n => n.Order) + .FirstOrDefault(n => n.Parameters.Any(p => !p.IsValueValid)); + + if (firstInvalidNode == null) + return false; + + var invalidNames = firstInvalidNode.Parameters + .Where(p => !p.IsValueValid) + .Select(p => p.DisplayName); + SetErrorStatus($"参数错误:算子 '{firstInvalidNode.DisplayName}' 的 {string.Join("、", invalidNames)} 输入不合理,请修正后重试。"); + return true; + } + + private void SetInfoStatus(string message) + { + IsStatusError = false; + StatusMessage = message; + } + + private void SetErrorStatus(string message) + { + IsStatusError = true; + StatusMessage = message; + PublishPipelinePreviewUpdated(PreviewImage ?? SourceImage, message); + } + private void LoadImage() { var dialog = new OpenFileDialog @@ -361,7 +446,7 @@ namespace XplorePlane.ViewModels } catch (Exception ex) { - StatusMessage = $"加载图像失败:{ex.Message}"; + SetErrorStatus($"加载图像失败:{ex.Message}"); _logger.Error(ex, "加载图像失败:{Path}", dialog.FileName); } } @@ -380,7 +465,7 @@ namespace XplorePlane.ViewModels SourceImage = bitmap; PreviewImage = bitmap; - StatusMessage = $"已加载图像:{Path.GetFileName(filePath)}"; + SetInfoStatus($"已加载图像:{Path.GetFileName(filePath)}"); PublishManualImageLoaded(bitmap, filePath); } @@ -391,7 +476,7 @@ namespace XplorePlane.ViewModels SourceImage = bitmap; PreviewImage = bitmap; - StatusMessage = $"已加载图像:{Path.GetFileName(filePath)}"; + SetInfoStatus($"已加载图像:{Path.GetFileName(filePath)}"); PublishManualImageLoaded(bitmap, filePath); if (runPipeline) @@ -419,7 +504,7 @@ namespace XplorePlane.ViewModels SourceImage = payload.Image; PreviewImage = payload.Image; - StatusMessage = $"已加载图像:{payload.FileName}"; + SetInfoStatus($"已加载图像:{payload.FileName}"); } private void CancelExecution() @@ -449,14 +534,15 @@ namespace XplorePlane.ViewModels PipelineName = "新建流水线"; PreviewImage = null; _currentFilePath = null; - StatusMessage = "已新建流水线"; + PipelineFileDisplayName = DefaultPipelineFileDisplayName; + SetInfoStatus("已新建流水线"); } private async Task SavePipelineAsync() { if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100) { - StatusMessage = "流水线名称不能为空且长度不超过 100 个字符"; + SetInfoStatus("流水线名称不能为空且长度不超过 100 个字符"); return; } @@ -473,13 +559,15 @@ namespace XplorePlane.ViewModels { if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100) { - StatusMessage = "流水线名称不能为空且长度不超过 100 个字符"; + SetInfoStatus("流水线名称不能为空且长度不超过 100 个字符"); return; } var dialog = new SaveFileDialog { - Filter = "图像处理流水线 (*.imw)|*.imw", + Filter = "XP 模块流水线 (*.xpm)|*.xpm", + DefaultExt = ".xpm", + AddExtension = true, FileName = PipelineName, InitialDirectory = GetPipelineDirectory() }; @@ -497,11 +585,12 @@ namespace XplorePlane.ViewModels { var model = BuildPipelineModel(); await _persistenceService.SaveAsync(model, filePath); - StatusMessage = $"流水线已保存:{Path.GetFileName(filePath)}"; + PipelineFileDisplayName = FormatPipelinePath(filePath); + SetInfoStatus($"流水线已保存:{Path.GetFileName(filePath)}"); } catch (IOException ex) { - StatusMessage = $"保存失败:{ex.Message}"; + SetErrorStatus($"保存失败:{ex.Message}"); } } @@ -515,11 +604,11 @@ namespace XplorePlane.ViewModels File.Delete(_currentFilePath); NewPipeline(); - StatusMessage = "流水线已删除"; + SetInfoStatus("流水线已删除"); } catch (IOException ex) { - StatusMessage = $"删除失败:{ex.Message}"; + SetErrorStatus($"删除失败:{ex.Message}"); } await Task.CompletedTask; } @@ -528,7 +617,8 @@ namespace XplorePlane.ViewModels { var dialog = new OpenFileDialog { - Filter = "图像处理流水线 (*.imw)|*.imw", + Filter = "XP 模块流水线 (*.xpm)|*.xpm", + DefaultExt = ".xpm", InitialDirectory = GetPipelineDirectory() }; @@ -544,6 +634,7 @@ namespace XplorePlane.ViewModels PipelineName = model.Name; SelectedDevice = model.DeviceId; _currentFilePath = dialog.FileName; + PipelineFileDisplayName = FormatPipelinePath(dialog.FileName); foreach (var nodeModel in model.Nodes) { @@ -568,12 +659,12 @@ namespace XplorePlane.ViewModels } _logger.Info("流水线已加载:{Name},节点数={Count}", model.Name, PipelineNodes.Count); - StatusMessage = $"已加载流水线:{model.Name}({PipelineNodes.Count} 个节点)"; + SetInfoStatus($"已加载流水线:{model.Name}({PipelineNodes.Count} 个节点)"); } catch (Exception ex) { _logger.Warn("加载流水线失败:{Error}", ex.Message); - StatusMessage = $"加载失败:{ex.Message}"; + SetErrorStatus($"加载失败:{ex.Message}"); } } @@ -622,5 +713,12 @@ namespace XplorePlane.ViewModels Directory.CreateDirectory(dir); return dir; } + + private static string FormatPipelinePath(string filePath) + { + return string.IsNullOrWhiteSpace(filePath) + ? DefaultPipelineFileDisplayName + : Path.GetFullPath(filePath).Replace('\\', '/'); + } } } diff --git a/XplorePlane/ViewModels/ImageProcessing/ProcessorParameterVM.cs b/XplorePlane/ViewModels/ImageProcessing/ProcessorParameterVM.cs index 7408d5a..019aaca 100644 --- a/XplorePlane/ViewModels/ImageProcessing/ProcessorParameterVM.cs +++ b/XplorePlane/ViewModels/ImageProcessing/ProcessorParameterVM.cs @@ -19,6 +19,7 @@ namespace XplorePlane.ViewModels MinValue = parameter.MinValue; MaxValue = parameter.MaxValue; Options = parameter.Options; + IsVisible = parameter.IsVisible; ParameterType = parameter.ValueType?.Name?.ToLowerInvariant() switch { "int32" or "int" => "int", @@ -34,7 +35,11 @@ namespace XplorePlane.ViewModels public object MinValue { get; } public object MaxValue { get; } public string[]? Options { get; } + public bool IsVisible { get; } public string ParameterType { get; } + public bool HasOptions => Options is { Length: > 0 }; + public bool IsBool => ParameterType == "bool"; + public bool IsTextInput => !IsBool && !HasOptions; public bool IsValueValid { @@ -48,8 +53,40 @@ namespace XplorePlane.ViewModels set { var normalizedValue = NormalizeValue(value); - if (SetProperty(ref _value, normalizedValue)) + if (!Equals(_value, normalizedValue)) + { + _value = normalizedValue; ValidateValue(normalizedValue); + RaisePropertyChanged(nameof(Value)); + RaisePropertyChanged(nameof(BoolValue)); + RaisePropertyChanged(nameof(SelectedOption)); + } + } + } + + public bool BoolValue + { + get => ParameterType == "bool" && TryConvertToBool(_value, out var boolValue) && boolValue; + set + { + if (ParameterType == "bool") + { + Value = value; + } + } + } + + public string SelectedOption + { + get => HasOptions + ? Convert.ToString(_value, CultureInfo.InvariantCulture) ?? string.Empty + : string.Empty; + set + { + if (HasOptions) + { + Value = value; + } } } diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 1809723..fdd75c9 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -21,7 +21,7 @@ namespace XplorePlane.ViewModels { public class MainViewModel : BindableBase { - private const double CncEditorHostWidth = 710d; + private const double CncEditorHostWidth = 502d; private readonly ILoggerService _logger; private readonly IContainerProvider _containerProvider; private readonly IEventAggregator _eventAggregator; diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index 975b87d..b320d06 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -9,22 +9,21 @@ xmlns:views="clr-namespace:XplorePlane.Views" xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc" d:DesignHeight="760" - d:DesignWidth="702" + d:DesignWidth="502" prism:ViewModelLocator.AutoWireViewModel="True" mc:Ignorable="d"> + + - - - - + Microsoft YaHei UI + + + + - - - + @@ -97,37 +120,81 @@ - - - - + +