PumpStation2D-Dynamic-Plan.md 11 KB

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 替代固定属性:

// 移除硬编码属性
// 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 布局数据结构

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 布局策略接口

public interface ILayoutEngine
{
    LayoutResult Calculate(ProjectProfile profile, Size canvasSize);
}

2.2 默认布局算法

输入:3 泵 + 1 池 + 0 风机
输出:
┌────────────────────────────────────┐
│         [集水池]                    │  ← 顶部居中
│            │                       │
│    ┌───────┼───────┐               │
│    │       │       │               │
│  [P1]    [P2]    [P3]             │  ← 中间均分
│    │       │       │               │
│════╪═══════╪═══════╪═══════════════│  ← 管道
└────────────────────────────────────┘
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 布局缓存

// 只在 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

只需修改调用方式:

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 值读取辅助方法

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 暴露字典属性

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 绑定调整

<!-- 当有项目档案时,显示动态 2D 视图 -->
<controls:PumpStation2DControl
    Profile="{Binding ProjectView.Profile}"
    AnalogValues="{Binding ProjectView.AnalogValues}"
    DigitalValues="{Binding ProjectView.DigitalValues}"
    IsVisible="{Binding HasProject}"/>

阶段五:交互与动画(1 天)

5.1 点击事件

// 设备点击 → 弹出操作面板
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 天)

// 检测是否传入固定属性(旧用法)
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 天)