# 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 ProfileProperty = AvaloniaProperty.Register(nameof(Profile)); public static readonly StyledProperty> AnalogValuesProperty = AvaloniaProperty.Register>(nameof(AnalogValues)); public static readonly StyledProperty> DigitalValuesProperty = AvaloniaProperty.Register>(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 Parameters { get; set; } // 额外参数 } public class LayoutResult { public List 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(); 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 _analogValues = new(); [ObservableProperty] private Dictionary _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 ``` --- ### 阶段五:交互与动画(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 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 天)