Переглянути джерело

docs: 添加 PumpStation2D/3D 动态化实施计划

- 数据驱动架构设计
- 布局引擎方案
- 6 天任务分解
- 替代方案(SVG 模板)
DESKTOP-74CLTRG\Leol 1 місяць тому
батько
коміт
f12bf0793f
1 змінених файлів з 374 додано та 0 видалено
  1. 374 0
      docs/PumpStation2D-Dynamic-Plan.md

+ 374 - 0
docs/PumpStation2D-Dynamic-Plan.md

@@ -0,0 +1,374 @@
+# PumpStation2D/3D 动态化实施计划
+
+## 目标
+
+将硬编码的 `PumpStation2DControl` 和 `PumpStation3DControl` 改造为数据驱动,能够根据 `ProjectProfile` 动态渲染任意数量/类型的设备。
+
+---
+
+## 现状分析
+
+### 当前架构(硬编码)
+
+```
+PumpStation2DControl
+├── Tank1LevelProperty ~ Tank4LevelProperty    ← 固定 4 个
+├── Pump1RunningProperty ~ Pump5RunningProperty ← 固定 5 个
+├── Fan1RunningProperty ~ Fan2RunningProperty   ← 固定 2 个
+└── Render(DrawingContext)
+    ├── DrawTank(index)     ← 位置硬编码
+    ├── DrawPump(index)     ← 位置硬编码
+    └── DrawFan(index)      ← 位置硬编码
+```
+
+**问题**:
+- 设备数量写死在控件属性里
+- 布局坐标硬编码(`x = 100 + index * 150`)
+- 无法适配不同泵站配置
+
+### 目标架构(数据驱动)
+
+```
+PumpStation2DControl
+├── Profile : ProjectProfile    ← 从项目档案读取设备列表
+├── LayoutEngine                ← 根据设备数量自动计算布局
+├── Renderers                   ← 复用现有 Renderer 绘制设备
+│   ├── TankRenderer
+│   ├── PumpRenderer
+│   ├── FanRenderer
+│   ├── ValveRenderer
+│   └── PipeRenderer
+└── Render(DrawingContext)
+    └── 遍历 Profile 设备,调用对应 Renderer
+```
+
+---
+
+## 实施步骤
+
+### 阶段一:数据模型扩展(1 天)
+
+#### 1.1 定义动态属性
+
+在 `PumpStation2DControl` 中用 `IList` 替代固定属性:
+
+```csharp
+// 移除硬编码属性
+// public double Tank1Level { get; set; }  ← 删除
+
+// 新增动态属性
+public static readonly StyledProperty<ProjectProfile> ProfileProperty =
+    AvaloniaProperty.Register<PumpStation2DControl, ProjectProfile>(nameof(Profile));
+
+public static readonly StyledProperty<IDictionary<string, float>> AnalogValuesProperty =
+    AvaloniaProperty.Register<PumpStation2DControl, IDictionary<string, float>>(nameof(AnalogValues));
+
+public static readonly StyledProperty<IDictionary<string, bool>> DigitalValuesProperty =
+    AvaloniaProperty.Register<PumpStation2DControl, IDictionary<string, bool>>(nameof(DigitalValues));
+
+// ViewModel 通过字典传值,不再绑定具体属性
+// AnalogValues = { "集水池.Level": 3.5, "1#泵.Current": 12.3, ... }
+// DigitalValues = { "1#泵.Run": true, "2#泵.Fault": false, ... }
+```
+
+#### 1.2 布局数据结构
+
+```csharp
+public class DeviceLayout
+{
+    public string DeviceId { get; set; }
+    public string DeviceType { get; set; }  // "Tank", "Pump", "Fan", "Valve"
+    public Rect Bounds { get; set; }        // 绘制区域
+    public Dictionary<string, object> Parameters { get; set; }  // 额外参数
+}
+
+public class LayoutResult
+{
+    public List<DeviceLayout> Devices { get; set; }
+    public List<(Point From, Point To)> Pipes { get; set; }  // 管道连接
+}
+```
+
+---
+
+### 阶段二:布局引擎(2 天)
+
+#### 2.1 布局策略接口
+
+```csharp
+public interface ILayoutEngine
+{
+    LayoutResult Calculate(ProjectProfile profile, Size canvasSize);
+}
+```
+
+#### 2.2 默认布局算法
+
+```
+输入:3 泵 + 1 池 + 0 风机
+输出:
+┌────────────────────────────────────┐
+│         [集水池]                    │  ← 顶部居中
+│            │                       │
+│    ┌───────┼───────┐               │
+│    │       │       │               │
+│  [P1]    [P2]    [P3]             │  ← 中间均分
+│    │       │       │               │
+│════╪═══════╪═══════╪═══════════════│  ← 管道
+└────────────────────────────────────┘
+```
+
+```csharp
+public class DefaultLayoutEngine : ILayoutEngine
+{
+    public LayoutResult Calculate(ProjectProfile profile, Size canvasSize)
+    {
+        var devices = new List<DeviceLayout>();
+        var pipes = new List<(Point, Point)>();
+
+        // 1. 计算水箱位置(顶部居中)
+        var tankY = canvasSize.Height * 0.15;
+        var tankSpacing = canvasSize.Width / (profile.Tanks.Count + 1);
+        for (int i = 0; i < profile.Tanks.Count; i++)
+        {
+            devices.Add(new DeviceLayout
+            {
+                DeviceId = profile.Tanks[i].Name,
+                DeviceType = "Tank",
+                Bounds = new Rect(tankSpacing * (i + 1) - 40, tankY, 80, 120)
+            });
+        }
+
+        // 2. 计算泵位置(中部均分)
+        var pumpY = canvasSize.Height * 0.5;
+        var pumpSpacing = canvasSize.Width / (profile.Pumps.Count + 1);
+        for (int i = 0; i < profile.Pumps.Count; i++)
+        {
+            devices.Add(new DeviceLayout
+            {
+                DeviceId = profile.Pumps[i].Name,
+                DeviceType = "Pump",
+                Bounds = new Rect(pumpSpacing * (i + 1) - 30, pumpY, 60, 60)
+            });
+        }
+
+        // 3. 计算管道连接(水箱→泵→出水总管)
+        // ...
+
+        return new LayoutResult { Devices = devices, Pipes = pipes };
+    }
+}
+```
+
+#### 2.3 布局缓存
+
+```csharp
+// 只在 Profile 变化时重新计算布局
+partial void OnProfileChanged(ProjectProfile oldProfile, ProjectProfile newProfile)
+{
+    _cachedLayout = _layoutEngine.Calculate(newProfile, new Size(960, 540));
+    InvalidateVisual();
+}
+```
+
+---
+
+### 阶段三:渲染层改造(2 天)
+
+#### 3.1 复用现有 Renderer
+
+现有 `Controls/Renderers/` 目录已有:
+- `TankRenderer.cs`
+- `PumpRenderer.cs`
+- `FanRenderer.cs`
+- `ValveRenderer.cs`
+- `PipeRenderer.cs`
+
+只需修改调用方式:
+
+```csharp
+protected override void Render(DrawingContext context)
+{
+    if (_cachedLayout == null) return;
+
+    // 遍历布局结果,调用对应 Renderer
+    foreach (var device in _cachedLayout.Devices)
+    {
+        switch (device.DeviceType)
+        {
+            case "Tank":
+                var level = GetAnalogValue($"{device.DeviceId}.Level");
+                TankRenderer.Draw(context, device.Bounds, level);
+                break;
+            case "Pump":
+                var running = GetDigitalValue($"{device.DeviceId}.Run");
+                var fault = GetDigitalValue($"{device.DeviceId}.Fault");
+                PumpRenderer.Draw(context, device.Bounds, running, fault);
+                break;
+            // ...
+        }
+    }
+
+    // 绘制管道
+    foreach (var pipe in _cachedLayout.Pipes)
+    {
+        PipeRenderer.Draw(context, pipe.From, pipe.To);
+    }
+}
+```
+
+#### 3.2 值读取辅助方法
+
+```csharp
+private float GetAnalogValue(string key)
+{
+    return AnalogValues != null && AnalogValues.TryGetValue(key, out var v) ? v : 0f;
+}
+
+private bool GetDigitalValue(string key)
+{
+    return DigitalValues != null && DigitalValues.TryGetValue(key, out var v) && v;
+}
+```
+
+---
+
+### 阶段四:ViewModel 适配(1 天)
+
+#### 4.1 ProjectViewModel 暴露字典属性
+
+```csharp
+public partial class ProjectViewModel : ObservableObject
+{
+    [ObservableProperty]
+    private Dictionary<string, float> _analogValues = new();
+
+    [ObservableProperty]
+    private Dictionary<string, bool> _digitalValues = new();
+
+    private void OnDataUpdated(PlcDataModel data)
+    {
+        // 更新字典
+        foreach (var pump in _profile.Pumps)
+        {
+            AnalogValues[$"{pump.Name}.Current"] = data.GetAnalogRegister($"{pump.Name}.Current");
+            DigitalValues[$"{pump.Name}.Run"] = data.GetDigitalRegister($"{pump.Name}.Run");
+            DigitalValues[$"{pump.Name}.Fault"] = data.GetDigitalRegister($"{pump.Name}.Fault");
+        }
+
+        foreach (var tank in _profile.Tanks)
+        {
+            AnalogValues[$"{tank.Name}.Level"] = data.GetAnalogRegister($"{tank.Name}.Level");
+        }
+
+        // 触发 PropertyChanged
+        OnPropertyChanged(nameof(AnalogValues));
+        OnPropertyChanged(nameof(DigitalValues));
+    }
+}
+```
+
+#### 4.2 ViewA 绑定调整
+
+```xml
+<!-- 当有项目档案时,显示动态 2D 视图 -->
+<controls:PumpStation2DControl
+    Profile="{Binding ProjectView.Profile}"
+    AnalogValues="{Binding ProjectView.AnalogValues}"
+    DigitalValues="{Binding ProjectView.DigitalValues}"
+    IsVisible="{Binding HasProject}"/>
+```
+
+---
+
+### 阶段五:交互与动画(1 天)
+
+#### 5.1 点击事件
+
+```csharp
+// 设备点击 → 弹出操作面板
+protected override void OnPointerPressed(PointerPressedEventArgs e)
+{
+    var pos = e.GetPosition(this);
+    var device = _cachedLayout?.Devices.FirstOrDefault(d => d.Bounds.Contains(pos));
+    if (device != null)
+    {
+        // 触发命令
+        DeviceClickedCommand?.Execute(device.DeviceId);
+    }
+}
+
+public static readonly StyledProperty<ICommand> DeviceClickedCommandProperty = ...;
+```
+
+#### 5.2 动画效果
+
+- 泵运行时叶轮旋转(复用 `PumpControl` 的动画逻辑)
+- 管道流动虚线动画(复用 `PipeLineControl`)
+- 故障设备红色闪烁
+
+---
+
+### 阶段六:向后兼容(0.5 天)
+
+```csharp
+// 检测是否传入固定属性(旧用法)
+if (Tank1Level > 0 || Pump1Running)
+{
+    // 回退到硬编码布局(兼容旧代码)
+    DrawLegacyLayout(context);
+}
+else if (Profile != null)
+{
+    // 使用动态布局
+    DrawDynamicLayout(context);
+}
+```
+
+---
+
+## 任务分解
+
+| # | 任务 | 预估 | 依赖 |
+|---|------|------|------|
+| 1 | 定义动态属性 + 布局数据结构 | 0.5 天 | 无 |
+| 2 | 实现 ILayoutEngine + DefaultLayoutEngine | 1.5 天 | #1 |
+| 3 | 改造 Render() 使用 Renderer | 1 天 | #1, #2 |
+| 4 | ProjectViewModel 暴露字典属性 | 0.5 天 | 无 |
+| 5 | ViewA 绑定调整 | 0.5 天 | #3, #4 |
+| 6 | 点击事件 + 操作面板 | 0.5 天 | #5 |
+| 7 | 动画效果(泵旋转/管道流动) | 0.5 天 | #3 |
+| 8 | 向后兼容处理 | 0.5 天 | #3 |
+| 9 | 单元测试 | 0.5 天 | #2 |
+| **总计** | | **~6 天** | |
+
+---
+
+## 风险与注意事项
+
+1. **布局算法复杂度**:不同泵站拓扑差异大(单池/多池、串联/并联),需要多种布局策略
+2. **性能**:设备数量很多时(>50),需要优化渲染(裁剪、缓存)
+3. **动画同步**:多个泵的旋转动画需要独立计时器
+4. **触控适配**:工业触摸屏需要大点击区域(≥ 40×40 px)
+
+---
+
+## 是否必要?
+
+**当前方案(ProjectView 卡片布局)已能满足扬杰二厂需求。**
+
+只有以下场景才需要动态 2D 工艺流程图:
+- 客户坚持要"仿真图"风格的界面
+- 需要在一个画面展示整个泵站拓扑关系
+- 有大屏展示需求(需要直观的工艺流程)
+
+如果以上都不需要,**维持现状即可**,不必投入 6 天开发。
+
+---
+
+## 替代方案
+
+如果只需简单的动态流程图,可以用 SVG/Canvas 方案:
+- 在 JSON 中定义流程图 SVG 模板
+- 运行时替换 SVG 中的变量(液位、状态)
+- 无需自定义控件,开发量更小(~2 天)