| @@ -9,253 +9,172 @@ | |||||
| #include <variant> | #include <variant> | ||||
| #include <vector> | #include <vector> | ||||
| // 普通触点的导通方式;常闭触点会对读取到的位值取反 | |||||
| enum class ContactMode | enum class ContactMode | ||||
| { | { | ||||
| NormallyOpen, // 常开:位值为 1 时触点导通 | |||||
| NormallyClosed // 常闭:位值为 0 时触点导通 | |||||
| NormallyOpen, | |||||
| NormallyClosed | |||||
| }; | }; | ||||
| // 线圈写入方式:普通写入、置位保持、复位清零 | |||||
| enum class CoilMode | enum class CoilMode | ||||
| { | { | ||||
| Normal, // 普通写入:直接写入当前逻辑结果 | |||||
| Set, // 置位:逻辑结果为 1 时保持为 1 | |||||
| Reset // 复位:逻辑结果为 1 时清零 | |||||
| Normal, | |||||
| Set, | |||||
| Reset | |||||
| }; | }; | ||||
| // 上升沿/下降沿触点的边沿方向 | |||||
| enum class EdgeMode | enum class EdgeMode | ||||
| { | { | ||||
| Rising, // 上升沿:信号从 0 变成 1 | |||||
| Falling // 下降沿:信号从 1 变成 0 | |||||
| Rising, | |||||
| Falling | |||||
| }; | }; | ||||
| // D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 | |||||
| enum class ComparisonOperator | enum class ComparisonOperator | ||||
| { | { | ||||
| Equal, // 等于 | |||||
| NotEqual, // 不等于 | |||||
| LessThan, // 小于 | |||||
| LessThanOrEqual, // 小于或等于 | |||||
| GreaterThan, // 大于 | |||||
| GreaterThanOrEqual // 大于或等于 | |||||
| Equal, | |||||
| NotEqual, | |||||
| LessThan, | |||||
| LessThanOrEqual, | |||||
| GreaterThan, | |||||
| GreaterThanOrEqual | |||||
| }; | }; | ||||
| // 字操作数可以来自常量,也可以来自 D 寄存器 | |||||
| enum class WordOperandKind | enum class WordOperandKind | ||||
| { | { | ||||
| Constant, // 使用固定数值 | |||||
| Register // 使用 D 寄存器中的数值 | |||||
| Constant, | |||||
| Register | |||||
| }; | }; | ||||
| // MOVE、ADD/SUB 共用的字操作数 | |||||
| struct WordOperand | struct WordOperand | ||||
| { | { | ||||
| WordOperandKind kind = WordOperandKind::Constant; // 操作数来源 | |||||
| RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址 | |||||
| std::int16_t constant = 0; // kind 为 Constant 时使用的数值 | |||||
| WordOperandKind kind = WordOperandKind::Constant; | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| std::int16_t constant = 0; | |||||
| // 常量始终有效;寄存器操作数必须是有效 D 地址 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| // 普通 M 寄存器触点,地址 + 常开常闭 | |||||
| struct ContactNodeConfig | struct ContactNodeConfig | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 | |||||
| ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| }; | }; | ||||
| // 边沿触点,地址 + 上升 / 下降沿 | |||||
| struct EdgeContactNodeConfig | struct EdgeContactNodeConfig | ||||
| { | { | ||||
| // 用于检测 M 寄存器的上升沿或下降沿 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| EdgeMode mode = EdgeMode::Rising; | EdgeMode mode = EdgeMode::Rising; | ||||
| }; | }; | ||||
| // 线圈输出配置,决定如何写入 M 寄存器 | |||||
| struct CoilNodeConfig | struct CoilNodeConfig | ||||
| { | { | ||||
| // 线圈要写入的 M 寄存器地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| CoilMode mode = CoilMode::Normal; | CoilMode mode = CoilMode::Normal; | ||||
| }; | }; | ||||
| // D 寄存器比较配置 | |||||
| struct CompareNodeConfig | struct CompareNodeConfig | ||||
| { | { | ||||
| // 要参与比较的 D 寄存器地址 | |||||
| RegisterAddress address{RegisterArea::D, 0}; | RegisterAddress address{RegisterArea::D, 0}; | ||||
| // 比较运算符 | |||||
| ComparisonOperator comparison = ComparisonOperator::Equal; | ComparisonOperator comparison = ComparisonOperator::Equal; | ||||
| // 与寄存器值比较的常量 | |||||
| std::int16_t value = 0; | std::int16_t value = 0; | ||||
| }; | }; | ||||
| // MOVE 输出:把一个字操作数写入目标 D 地址 | |||||
| struct MoveNodeConfig | struct MoveNodeConfig | ||||
| { | { | ||||
| WordOperand source; // 要写入的源操作数 | |||||
| RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 | |||||
| WordOperand source; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| }; | }; | ||||
| enum class ArithmeticOperation | enum class ArithmeticOperation | ||||
| { | { | ||||
| Add, // 加法 | |||||
| Subtract // 减法 | |||||
| Add, | |||||
| Subtract | |||||
| }; | }; | ||||
| // ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 | |||||
| struct ArithmeticNodeConfig | struct ArithmeticNodeConfig | ||||
| { | { | ||||
| ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法 | |||||
| WordOperand left; // 左操作数 | |||||
| WordOperand right; // 右操作数 | |||||
| RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址 | |||||
| ArithmeticOperation operation = ArithmeticOperation::Add; | |||||
| WordOperand left; | |||||
| WordOperand right; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| }; | }; | ||||
| // 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令 | |||||
| using LogicNodeConfig = std::variant< | using LogicNodeConfig = std::variant< | ||||
| ContactNodeConfig, // 普通常开或常闭触点 | |||||
| EdgeContactNodeConfig, // 上升沿或下降沿触点 | |||||
| CoilNodeConfig, // 普通、置位或复位线圈 | |||||
| CompareNodeConfig, // D 寄存器比较节点 | |||||
| MoveNodeConfig, // MOVE 数据传送输出 | |||||
| ArithmeticNodeConfig>; // ADD 或 SUB 算术输出 | |||||
| ContactNodeConfig, | |||||
| EdgeContactNodeConfig, | |||||
| CoilNodeConfig, | |||||
| CompareNodeConfig, | |||||
| MoveNodeConfig, | |||||
| ArithmeticNodeConfig>; | |||||
| // 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数 | |||||
| std::optional<RegisterAddress> registerAddressForLogicNode( | std::optional<RegisterAddress> registerAddressForLogicNode( | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| // 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建 | |||||
| void collectRegisterAddressesForLogicNode( | void collectRegisterAddressesForLogicNode( | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| std::vector<RegisterAddress> *addresses); | std::vector<RegisterAddress> *addresses); | ||||
| // 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿 | |||||
| struct LogicNode | struct LogicNode | ||||
| { | { | ||||
| std::string id; // 节点唯一 ID | |||||
| LogicNodeConfig config; // 节点的具体指令配置 | |||||
| bool configured = true; // 是否已完成配置 | |||||
| std::string id; | |||||
| LogicNodeConfig config; | |||||
| bool configured = true; | |||||
| // 校验节点 ID、配置类型和配置内容 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool isConfigured() const; | bool isConfigured() const; | ||||
| // 判断节点能否放在条件区或固定输出槽 | |||||
| bool isCondition() const; | bool isCondition() const; | ||||
| bool isOutput() const; | bool isOutput() const; | ||||
| }; | }; | ||||
| enum class ConditionExpressionKind | |||||
| { | |||||
| Node, // 一个实际的条件节点 | |||||
| Wire, // 一段横线 | |||||
| Gap, // 一段明确断开的空白网格 | |||||
| Series, // 多个条件串联,必须全部满足 | |||||
| Parallel // 多个条件并联,满足任意一条即可 | |||||
| }; | |||||
| // 条件区中的持久化横线;columnSpan 表示跨越的网格列数 | |||||
| struct WireSegment | |||||
| enum class LadderCellKind | |||||
| { | { | ||||
| static constexpr int kMinimumColumnSpan = 1; | |||||
| static constexpr int kMaximumColumnSpan = | |||||
| ProjectLimits::kMaximumConditionColumns; | |||||
| int columnSpan = 1; // 横线占用的网格列数 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| Gap, | |||||
| Wire, | |||||
| Node | |||||
| }; | }; | ||||
| // 条件区中的持久化断路;columnSpan 表示连续空白网格列数 | |||||
| struct GapSegment | |||||
| // 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位 | |||||
| struct LadderCell | |||||
| { | { | ||||
| static constexpr int kMinimumColumnSpan = 1; | |||||
| static constexpr int kMaximumColumnSpan = | |||||
| ProjectLimits::kMaximumConditionColumns; | |||||
| int columnSpan = 1; // 断路占用的网格列数 | |||||
| std::string id; | |||||
| LadderCellKind kind = LadderCellKind::Gap; | |||||
| std::optional<LogicNode> node; | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | |||||
| struct ConditionExpression | |||||
| // 两个相邻视觉行之间、指定列边界上的一段竖线 | |||||
| struct VerticalConnection | |||||
| { | { | ||||
| std::string id; // 表达式唯一 ID | |||||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 | |||||
| std::optional<LogicNode> node; // kind 为 Node 时保存的节点 | |||||
| std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线 | |||||
| std::optional<GapSegment> gap; // kind 为 Gap 时保存的断路 | |||||
| std::vector<ConditionExpression> children; // 串联或并联的子表达式 | |||||
| std::string id; | |||||
| std::string upperRungId; | |||||
| std::string lowerRungId; | |||||
| int columnBoundary = 0; | |||||
| // 把一个逻辑节点包装成条件表达式 | |||||
| static ConditionExpression fromNode(LogicNode node); | |||||
| // 创建一段指定列数的横线表达式 | |||||
| static ConditionExpression fromWire(std::string id, int column_span = 1); | |||||
| // 创建一段指定列数的断路表达式 | |||||
| static ConditionExpression fromGap(std::string id, int column_span = 1); | |||||
| // 编辑态校验允许空配置节点,但必须保持树结构合法 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| // 运行态校验额外要求每个节点都已经配置完成 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| // 删除节点后整理表达式结构 | |||||
| void normalizeConditionExpression(std::optional<ConditionExpression> *expression); | |||||
| // 按节点 ID 查找条件节点,只读版本 | |||||
| const LogicNode *findConditionNode( | |||||
| const ConditionExpression &expression, const std::string &node_id); | |||||
| // 按节点 ID 查找条件节点,可修改版本 | |||||
| LogicNode *findConditionNode( | |||||
| ConditionExpression &expression, const std::string &node_id); | |||||
| // 按表达式 ID 查找子表达式,只读版本 | |||||
| const ConditionExpression *findConditionExpression( | |||||
| const ConditionExpression &expression, const std::string &expression_id); | |||||
| // 按表达式 ID 查找子表达式,可修改版本 | |||||
| ConditionExpression *findConditionExpression( | |||||
| ConditionExpression &expression, const std::string &expression_id); | |||||
| // 收集表达式中的所有条件节点 | |||||
| void collectConditionNodes( | |||||
| const ConditionExpression &expression, std::vector<const LogicNode *> *nodes); | |||||
| // 收集表达式和子表达式的所有 ID | |||||
| void collectConditionExpressionIds( | |||||
| const ConditionExpression &expression, std::vector<std::string> *ids); | |||||
| // 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满 | |||||
| // 连续梯形图的一条视觉行,不再是隔离的网络容器 | |||||
| struct LadderRung | struct LadderRung | ||||
| { | { | ||||
| std::string id; // 网络唯一 ID | |||||
| std::string name; // 网络名称 | |||||
| std::string comment; // 网络注释 | |||||
| std::optional<ConditionExpression> condition; // 条件区表达式;空值只用于空网络草稿 | |||||
| std::optional<LogicNode> output; // 网络右侧的输出指令 | |||||
| std::string id; | |||||
| std::string name; | |||||
| std::string comment; | |||||
| std::optional<LogicNode> output; | |||||
| std::vector<LadderCell> cells; | |||||
| // 编辑态允许没有条件或输出,便于逐步搭建网络 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| // 只检查表达式拓扑、列数和输出位置 | |||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| // 运行态要求有输出且所有引用都已配置 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| // 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它 | |||||
| // 一张连续梯形图,网络由横竖连接关系自然形成 | |||||
| struct ControlLogic | struct ControlLogic | ||||
| { | { | ||||
| std::string id; // 控制逻辑唯一 ID | |||||
| std::string name; // 控制逻辑名称 | |||||
| std::vector<LadderRung> rungs; // 很多条 LadderRung(梯形图网络/梯级) | |||||
| bool enabled = true; // 是否参与运行扫描 | |||||
| std::string id; | |||||
| std::string name; | |||||
| std::vector<LadderRung> rungs; | |||||
| bool enabled = true; | |||||
| std::vector<VerticalConnection> verticalConnections; | |||||
| // 校验可保存结构 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validate( | bool validate( | ||||
| const ProjectLimitSettings &limits, | const ProjectLimitSettings &limits, | ||||
| @@ -264,9 +183,21 @@ struct ControlLogic | |||||
| bool validateStructure( | bool validateStructure( | ||||
| const ProjectLimitSettings &limits, | const ProjectLimitSettings &limits, | ||||
| std::string *error = nullptr) const; | std::string *error = nullptr) const; | ||||
| // 校验运行所需资源和网络配置 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| bool validateForRunning( | bool validateForRunning( | ||||
| const ProjectLimitSettings &limits, | const ProjectLimitSettings &limits, | ||||
| std::string *error = nullptr) const; | std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| const LadderCell *findLadderCell( | |||||
| const LadderRung &rung, const std::string &cell_id); | |||||
| LadderCell *findLadderCell( | |||||
| LadderRung &rung, const std::string &cell_id); | |||||
| const VerticalConnection *findVerticalConnection( | |||||
| const ControlLogic &logic, const std::string &connection_id); | |||||
| VerticalConnection *findVerticalConnection( | |||||
| ControlLogic &logic, const std::string &connection_id); | |||||
| void collectConditionNodes( | |||||
| const LadderRung &rung, std::vector<const LogicNode *> *nodes); | |||||
| void collectLogicNodes( | |||||
| const ControlLogic &logic, std::vector<const LogicNode *> *nodes); | |||||
| @@ -2,7 +2,8 @@ | |||||
| #include <cstddef> | #include <cstddef> | ||||
| // 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 | |||||
| // 程序允许的绝对硬上限统一放在这里,避免 UI、服务和文件读写各自使用不同数字 | |||||
| // application.ini 只能收紧其中支持配置的上限,不能突破这些硬上限 | |||||
| namespace ProjectLimits { | namespace ProjectLimits { | ||||
| constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存 | constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存 | ||||
| @@ -13,16 +14,14 @@ constexpr std::size_t kMaximumHmiControlsPerProject = 2048U; // 一个工程最 | |||||
| constexpr std::size_t kMaximumAlarmDefinitions = 256U; // 一个工程最多配置 256 条报警 | constexpr std::size_t kMaximumAlarmDefinitions = 256U; // 一个工程最多配置 256 条报警 | ||||
| constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释 | constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释 | ||||
| constexpr std::size_t kMaximumControlLogics = 32U; // 一个工程最多放 32 组梯形图控制逻辑 | constexpr std::size_t kMaximumControlLogics = 32U; // 一个工程最多放 32 组梯形图控制逻辑 | ||||
| constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 个网络 | |||||
| constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 个网络 | |||||
| constexpr std::size_t kMaximumExpressionNodesPerRung = 1024U; // 一个网络里的触点、横线和内部结构最多共 1024 个 | |||||
| constexpr std::size_t kMaximumExpressionDepth = 12U; // 并联套并联时最多套 12 层,防止结构无限变复杂 | |||||
| constexpr std::size_t kMaximumExpressionChildren = 64U; // 一个串联组或并联组最多放 64 个子项 | |||||
| constexpr int kMaximumConditionColumns = 10; // 一个网络前面的条件区最多 10 列 | |||||
| constexpr int kMaximumLadderColumns = 11; // 一个网络总共 11 列,最后 1 列专门放输出 | |||||
| constexpr int kMaximumLogicRows = 64; // 一个网络上下最多 64 行并联支路 | |||||
| constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 行 | |||||
| constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 行 | |||||
| constexpr int kMaximumConditionColumns = 10; // 每行固定 10 个条件网格 | |||||
| constexpr int kMaximumLadderColumns = 11; // 第 11 列固定用于输出 | |||||
| constexpr std::size_t kMaximumVerticalConnectionsPerLogic = | |||||
| (kMaximumRungsPerLogic - 1U) | |||||
| * static_cast<std::size_t>(kMaximumConditionColumns + 1); // 一组控制逻辑最多保存的竖线总数 | |||||
| static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | ||||
| constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 | constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 | ||||
| @@ -65,34 +64,36 @@ constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一 | |||||
| constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱 | constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱 | ||||
| constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 | constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 | ||||
| } | |||||
| } // namespace ProjectLimits | |||||
| // 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限 | |||||
| // 本次程序实际使用的可配置上限;默认采用代码硬上限,INI 只能将它们调小 | |||||
| struct ProjectLimitSettings | struct ProjectLimitSettings | ||||
| { | { | ||||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; | |||||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; // 一个工程允许的 HMI 页面数 | |||||
| std::size_t maximumHmiControlsPerPage = | std::size_t maximumHmiControlsPerPage = | ||||
| ProjectLimits::kMaximumHmiControlsPerPage; | |||||
| ProjectLimits::kMaximumHmiControlsPerPage; // 每个 HMI 页面允许的控件数 | |||||
| std::size_t maximumAlarmDefinitions = | std::size_t maximumAlarmDefinitions = | ||||
| ProjectLimits::kMaximumAlarmDefinitions; | |||||
| std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; | |||||
| std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; | |||||
| int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; | |||||
| ProjectLimits::kMaximumAlarmDefinitions; // 一个工程允许的报警数 | |||||
| std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; // 一个工程允许的控制逻辑组数 | |||||
| std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; // 每组控制逻辑允许的梯形图行数 | |||||
| int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; // 输出面板最多保留的消息数 | |||||
| }; | }; | ||||
| // 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制 | |||||
| // 新建 HMI 页面使用的默认尺寸;不会修改已有页面,也不能超出上面的宽高硬限制 | |||||
| struct HmiDefaultSettings | struct HmiDefaultSettings | ||||
| { | { | ||||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; | |||||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; | |||||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; // 新建页面的默认宽度 | |||||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; // 新建页面的默认高度 | |||||
| }; | }; | ||||
| // 返回长期有效的默认上限,供没有传入 INI 配置的代码使用 | |||||
| inline const ProjectLimitSettings &defaultProjectLimitSettings() | inline const ProjectLimitSettings &defaultProjectLimitSettings() | ||||
| { | { | ||||
| static const ProjectLimitSettings settings; | static const ProjectLimitSettings settings; | ||||
| return settings; | return settings; | ||||
| } | } | ||||
| // 返回长期有效的默认页面尺寸,供没有传入 INI 配置的代码使用 | |||||
| inline const HmiDefaultSettings &defaultHmiSettings() | inline const HmiDefaultSettings &defaultHmiSettings() | ||||
| { | { | ||||
| static const HmiDefaultSettings settings; | static const HmiDefaultSettings settings; | ||||
| @@ -401,10 +401,7 @@ bool Project::validate( | |||||
| for (const LadderRung &rung : logic.rungs) | for (const LadderRung &rung : logic.rungs) | ||||
| { | { | ||||
| std::vector<const LogicNode *> nodes; | std::vector<const LogicNode *> nodes; | ||||
| if (rung.condition.has_value()) | |||||
| { | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| } | |||||
| collectConditionNodes(rung, &nodes); | |||||
| if (rung.output.has_value()) | if (rung.output.has_value()) | ||||
| { | { | ||||
| nodes.push_back(&*rung.output); | nodes.push_back(&*rung.output); | ||||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||||
| // 工程显示名称 | // 工程显示名称 | ||||
| std::string name; | std::string name; | ||||
| // 工程文件格式版本 | // 工程文件格式版本 | ||||
| std::string formatVersion = "1.0"; | |||||
| std::string formatVersion = "2.0"; | |||||
| }; | }; | ||||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | ||||
| @@ -15,41 +15,41 @@ ModeTransitionResult RuntimeState::enterEditing() | |||||
| // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 | // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 | ||||
| if (mode_ == ApplicationMode::Editing) | if (mode_ == ApplicationMode::Editing) | ||||
| { | { | ||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||||
| } | } | ||||
| mode_ = ApplicationMode::Editing; | mode_ = ApplicationMode::Editing; | ||||
| return {true, ModeTransitionError::None}; | |||||
| return {true, ModeTransitionError::None, {}}; | |||||
| } | } | ||||
| ModeTransitionResult RuntimeState::enterOfflineRunning() | ModeTransitionResult RuntimeState::enterOfflineRunning() | ||||
| { | { | ||||
| if (mode_ == ApplicationMode::OfflineRunning) | if (mode_ == ApplicationMode::OfflineRunning) | ||||
| { | { | ||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||||
| } | } | ||||
| if (mode_ != ApplicationMode::Editing) | if (mode_ != ApplicationMode::Editing) | ||||
| { | { | ||||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||||
| return {false, ModeTransitionError::MustReturnToEditing, {}}; | |||||
| } | } | ||||
| mode_ = ApplicationMode::OfflineRunning; | mode_ = ApplicationMode::OfflineRunning; | ||||
| return {true, ModeTransitionError::None}; | |||||
| return {true, ModeTransitionError::None, {}}; | |||||
| } | } | ||||
| ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) | ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) | ||||
| { | { | ||||
| if (mode_ == ApplicationMode::OnlineRunning) | if (mode_ == ApplicationMode::OnlineRunning) | ||||
| { | { | ||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||||
| } | } | ||||
| if (mode_ != ApplicationMode::Editing) | if (mode_ != ApplicationMode::Editing) | ||||
| { | { | ||||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||||
| return {false, ModeTransitionError::MustReturnToEditing, {}}; | |||||
| } | } | ||||
| if (!initial_plc_read_completed) | if (!initial_plc_read_completed) | ||||
| { | { | ||||
| // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 | // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 | ||||
| return {false, ModeTransitionError::InitialPlcReadRequired}; | |||||
| return {false, ModeTransitionError::InitialPlcReadRequired, {}}; | |||||
| } | } | ||||
| mode_ = ApplicationMode::OnlineRunning; | mode_ = ApplicationMode::OnlineRunning; | ||||
| return {true, ModeTransitionError::None}; | |||||
| return {true, ModeTransitionError::None, {}}; | |||||
| } | } | ||||
| @@ -1,5 +1,7 @@ | |||||
| #pragma once | #pragma once | ||||
| #include <string> | |||||
| // 应用当前运行模式;三个状态之间不能直接从离线切到真机 | // 应用当前运行模式;三个状态之间不能直接从离线切到真机 | ||||
| enum class ApplicationMode | enum class ApplicationMode | ||||
| { | { | ||||
| @@ -86,6 +88,8 @@ struct ModeTransitionResult | |||||
| bool succeeded = false; | bool succeeded = false; | ||||
| // 切换失败原因 | // 切换失败原因 | ||||
| ModeTransitionError error = ModeTransitionError::None; | ModeTransitionError error = ModeTransitionError::None; | ||||
| // 服务层补充的具体失败原因,状态机自身可以留空 | |||||
| std::string detail; | |||||
| }; | }; | ||||
| // 保存当前模式并执行最基本的状态机约束 | // 保存当前模式并执行最基本的状态机约束 | ||||
| @@ -18,7 +18,7 @@ | |||||
| namespace { | namespace { | ||||
| // 当前读写实现支持的工程文件格式版本 | // 当前读写实现支持的工程文件格式版本 | ||||
| constexpr const char *kCurrentFormatVersion = "1.0"; | |||||
| constexpr const char *kCurrentFormatVersion = "2.0"; | |||||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | ||||
| struct ParseState | struct ParseState | ||||
| @@ -1283,185 +1283,30 @@ bool parseLogicNode( | |||||
| return true; | return true; | ||||
| } | } | ||||
| QString expressionKindText(ConditionExpressionKind kind) | |||||
| { | |||||
| switch (kind) | |||||
| { | |||||
| case ConditionExpressionKind::Node: | |||||
| return QStringLiteral("node"); | |||||
| case ConditionExpressionKind::Wire: | |||||
| return QStringLiteral("wire"); | |||||
| case ConditionExpressionKind::Gap: | |||||
| return QStringLiteral("gap"); | |||||
| case ConditionExpressionKind::Series: | |||||
| return QStringLiteral("series"); | |||||
| case ConditionExpressionKind::Parallel: | |||||
| return QStringLiteral("parallel"); | |||||
| } | |||||
| return {}; | |||||
| } | |||||
| QJsonObject serializeConditionExpression(const ConditionExpression &expression) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("id"), fromUtf8(expression.id)); | |||||
| object.insert(QStringLiteral("kind"), expressionKindText(expression.kind)); | |||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| object.insert(QStringLiteral("node"), serializeLogicNode(*expression.node)); | |||||
| } | |||||
| else if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | |||||
| } | |||||
| else if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); | |||||
| } | |||||
| else | |||||
| { | |||||
| QJsonArray children; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | |||||
| children.append(serializeConditionExpression(child)); | |||||
| } | |||||
| object.insert(QStringLiteral("children"), children); | |||||
| } | |||||
| return object; | |||||
| } | |||||
| bool parseConditionExpression( | |||||
| const QJsonObject &object, | |||||
| const std::string &context, | |||||
| ConditionExpression *expression, | |||||
| ParseState *state, | |||||
| std::size_t depth, | |||||
| std::size_t *node_count) | |||||
| { | |||||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + " 的嵌套深度超过 12 层"); | |||||
| } | |||||
| ++*node_count; | |||||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + " 的表达式节点总数超过 1024 个"); | |||||
| } | |||||
| std::string kind; | |||||
| if (!readString( | |||||
| object, "id", context, &expression->id, state, | |||||
| ProjectLimits::kMaximumIdBytes) | |||||
| || !readString(object, "kind", context, &kind, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (kind == "node") | |||||
| { | |||||
| QJsonObject node; | |||||
| if (!readObject(object, "node", context, &node, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| LogicNode parsed_node; | |||||
| if (!parseLogicNode(node, context + ".node", &parsed_node, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| expression->kind = ConditionExpressionKind::Node; | |||||
| expression->node = std::move(parsed_node); | |||||
| return true; | |||||
| } | |||||
| if (kind == "wire") | |||||
| { | |||||
| int column_span = 0; | |||||
| if (!readInt( | |||||
| object, | |||||
| "columnSpan", | |||||
| context, | |||||
| WireSegment::kMinimumColumnSpan, | |||||
| WireSegment::kMaximumColumnSpan, | |||||
| &column_span, | |||||
| state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| expression->kind = ConditionExpressionKind::Wire; | |||||
| expression->wire = WireSegment{column_span}; | |||||
| return true; | |||||
| } | |||||
| if (kind == "gap") | |||||
| { | |||||
| int column_span = 0; | |||||
| if (!readInt( | |||||
| object, | |||||
| "columnSpan", | |||||
| context, | |||||
| GapSegment::kMinimumColumnSpan, | |||||
| GapSegment::kMaximumColumnSpan, | |||||
| &column_span, | |||||
| state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| expression->kind = ConditionExpressionKind::Gap; | |||||
| expression->gap = GapSegment{column_span}; | |||||
| return true; | |||||
| } | |||||
| if (kind != "series" && kind != "parallel") | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".kind 必须是 node、wire、gap、series 或 parallel"); | |||||
| } | |||||
| QJsonArray children; | |||||
| if (!readArray( | |||||
| object, "children", context, &children, state, | |||||
| static_cast<int>(ProjectLimits::kMaximumExpressionChildren))) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| expression->kind = kind == "series" | |||||
| ? ConditionExpressionKind::Series : ConditionExpressionKind::Parallel; | |||||
| expression->children.reserve(static_cast<std::size_t>(children.size())); | |||||
| for (int index = 0; index < children.size(); ++index) | |||||
| { | |||||
| if (!children.at(index).isObject()) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".children 的元素必须是对象"); | |||||
| } | |||||
| ConditionExpression child; | |||||
| if (!parseConditionExpression( | |||||
| children.at(index).toObject(), | |||||
| context + ".children[" + std::to_string(index) + ']', | |||||
| &child, | |||||
| state, | |||||
| depth + 1U, | |||||
| node_count)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| expression->children.push_back(std::move(child)); | |||||
| } | |||||
| return true; | |||||
| } | |||||
| QJsonObject serializeLadderRung(const LadderRung &rung) | QJsonObject serializeLadderRung(const LadderRung &rung) | ||||
| { | { | ||||
| QJsonObject object; | QJsonObject object; | ||||
| object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | ||||
| object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | ||||
| object.insert(QStringLiteral("comment"), fromUtf8(rung.comment)); | object.insert(QStringLiteral("comment"), fromUtf8(rung.comment)); | ||||
| object.insert( | |||||
| QStringLiteral("condition"), | |||||
| rung.condition.has_value() | |||||
| ? QJsonValue(serializeConditionExpression(*rung.condition)) | |||||
| : QJsonValue(QJsonValue::Null)); | |||||
| QJsonArray cells; | |||||
| for (const LadderCell &cell : rung.cells) | |||||
| { | |||||
| QJsonObject cell_object; | |||||
| cell_object.insert(QStringLiteral("id"), fromUtf8(cell.id)); | |||||
| cell_object.insert( | |||||
| QStringLiteral("kind"), | |||||
| cell.kind == LadderCellKind::Node | |||||
| ? QStringLiteral("node") | |||||
| : cell.kind == LadderCellKind::Wire | |||||
| ? QStringLiteral("wire") : QStringLiteral("gap")); | |||||
| if (cell.node.has_value()) | |||||
| { | |||||
| cell_object.insert(QStringLiteral("node"), serializeLogicNode(*cell.node)); | |||||
| } | |||||
| cells.append(cell_object); | |||||
| } | |||||
| object.insert(QStringLiteral("cells"), cells); | |||||
| object.insert( | object.insert( | ||||
| QStringLiteral("output"), | QStringLiteral("output"), | ||||
| rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output)) | rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output)) | ||||
| @@ -1475,42 +1320,71 @@ bool parseLadderRung( | |||||
| LadderRung *rung, | LadderRung *rung, | ||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonValue condition; | |||||
| QJsonArray cells; | |||||
| QJsonValue output; | QJsonValue output; | ||||
| if (!readString( | if (!readString( | ||||
| object, "id", context, &rung->id, state, | object, "id", context, &rung->id, state, | ||||
| ProjectLimits::kMaximumIdBytes) | ProjectLimits::kMaximumIdBytes) | ||||
| || !readString(object, "name", context, &rung->name, state) | || !readString(object, "name", context, &rung->name, state) | ||||
| || !readString(object, "comment", context, &rung->comment, state) | || !readString(object, "comment", context, &rung->comment, state) | ||||
| || !readArray(object, "cells", context, &cells, state, | |||||
| ProjectLimits::kMaximumConditionColumns) | |||||
| || !readValue(object, "output", context, &output, state)) | || !readValue(object, "output", context, &output, state)) | ||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (!readValue(object, "condition", context, &condition, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (condition.isNull()) | |||||
| { | |||||
| rung->condition.reset(); | |||||
| } | |||||
| else if (!condition.isObject()) | |||||
| if (cells.size() != ProjectLimits::kMaximumConditionColumns) | |||||
| { | { | ||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".condition 必须是对象或 null"); | |||||
| return state->fail(ProjectStorageError::InvalidField, | |||||
| context + ".cells 必须严格包含 10 个网格"); | |||||
| } | } | ||||
| else | |||||
| for (int index = 0; index < cells.size(); ++index) | |||||
| { | { | ||||
| ConditionExpression parsed_condition; | |||||
| std::size_t expression_node_count = 0U; | |||||
| if (!parseConditionExpression( | |||||
| condition.toObject(), context + ".condition", &parsed_condition, | |||||
| state, 1U, &expression_node_count)) | |||||
| if (!cells.at(index).isObject()) | |||||
| { | |||||
| return state->fail(ProjectStorageError::InvalidField, | |||||
| context + ".cells 的元素必须是对象"); | |||||
| } | |||||
| const QJsonObject cell_object = cells.at(index).toObject(); | |||||
| const std::string cell_context = context + ".cells[" | |||||
| + std::to_string(index) + ']'; | |||||
| LadderCell cell; | |||||
| std::string kind; | |||||
| if (!readString(cell_object, "id", cell_context, &cell.id, state, | |||||
| ProjectLimits::kMaximumIdBytes) | |||||
| || !readString(cell_object, "kind", cell_context, &kind, state)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| rung->condition = std::move(parsed_condition); | |||||
| if (kind == "gap") | |||||
| { | |||||
| cell.kind = LadderCellKind::Gap; | |||||
| } | |||||
| else if (kind == "wire") | |||||
| { | |||||
| cell.kind = LadderCellKind::Wire; | |||||
| } | |||||
| else if (kind == "node") | |||||
| { | |||||
| cell.kind = LadderCellKind::Node; | |||||
| QJsonObject node_object; | |||||
| if (!readObject(cell_object, "node", cell_context, &node_object, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| LogicNode node; | |||||
| if (!parseLogicNode(node_object, cell_context + ".node", &node, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| cell.node = std::move(node); | |||||
| } | |||||
| else | |||||
| { | |||||
| return state->fail(ProjectStorageError::InvalidField, | |||||
| cell_context + ".kind 必须是 gap、wire 或 node"); | |||||
| } | |||||
| rung->cells.push_back(std::move(cell)); | |||||
| } | } | ||||
| if (output.isNull()) | if (output.isNull()) | ||||
| { | { | ||||
| @@ -1539,11 +1413,22 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) | |||||
| { | { | ||||
| rungs.append(serializeLadderRung(rung)); | rungs.append(serializeLadderRung(rung)); | ||||
| } | } | ||||
| QJsonArray connections; | |||||
| for (const VerticalConnection &connection : logic.verticalConnections) | |||||
| { | |||||
| QJsonObject item; | |||||
| item.insert(QStringLiteral("id"), fromUtf8(connection.id)); | |||||
| item.insert(QStringLiteral("upperRungId"), fromUtf8(connection.upperRungId)); | |||||
| item.insert(QStringLiteral("lowerRungId"), fromUtf8(connection.lowerRungId)); | |||||
| item.insert(QStringLiteral("columnBoundary"), connection.columnBoundary); | |||||
| connections.append(item); | |||||
| } | |||||
| QJsonObject object; | QJsonObject object; | ||||
| object.insert(QStringLiteral("id"), fromUtf8(logic.id)); | object.insert(QStringLiteral("id"), fromUtf8(logic.id)); | ||||
| object.insert(QStringLiteral("name"), fromUtf8(logic.name)); | object.insert(QStringLiteral("name"), fromUtf8(logic.name)); | ||||
| object.insert(QStringLiteral("enabled"), logic.enabled); | object.insert(QStringLiteral("enabled"), logic.enabled); | ||||
| object.insert(QStringLiteral("rungs"), rungs); | object.insert(QStringLiteral("rungs"), rungs); | ||||
| object.insert(QStringLiteral("verticalConnections"), connections); | |||||
| return object; | return object; | ||||
| } | } | ||||
| @@ -1555,6 +1440,7 @@ bool parseControlLogic( | |||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonArray rungs; | QJsonArray rungs; | ||||
| QJsonArray connections; | |||||
| if (!readString( | if (!readString( | ||||
| object, "id", context, &logic->id, state, | object, "id", context, &logic->id, state, | ||||
| ProjectLimits::kMaximumIdBytes) | ProjectLimits::kMaximumIdBytes) | ||||
| @@ -1562,7 +1448,10 @@ bool parseControlLogic( | |||||
| || !readBool(object, "enabled", context, &logic->enabled, state) | || !readBool(object, "enabled", context, &logic->enabled, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "rungs", context, &rungs, state, | object, "rungs", context, &rungs, state, | ||||
| static_cast<int>(limits.maximumRungsPerLogic))) | |||||
| static_cast<int>(limits.maximumRungsPerLogic)) | |||||
| || !readArray( | |||||
| object, "verticalConnections", context, &connections, state, | |||||
| static_cast<int>(ProjectLimits::kMaximumVerticalConnectionsPerLogic))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1586,6 +1475,35 @@ bool parseControlLogic( | |||||
| } | } | ||||
| logic->rungs.push_back(std::move(rung)); | logic->rungs.push_back(std::move(rung)); | ||||
| } | } | ||||
| for (int index = 0; index < connections.size(); ++index) | |||||
| { | |||||
| if (!connections.at(index).isObject()) | |||||
| { | |||||
| return state->fail(ProjectStorageError::InvalidField, | |||||
| context + ".verticalConnections 的元素必须是对象"); | |||||
| } | |||||
| const QJsonObject item = connections.at(index).toObject(); | |||||
| VerticalConnection connection; | |||||
| int boundary = 0; | |||||
| const std::string item_context = context + ".verticalConnections[" | |||||
| + std::to_string(index) + ']'; | |||||
| if (!readString(item, "id", item_context, &connection.id, state, | |||||
| ProjectLimits::kMaximumIdBytes) | |||||
| || !readString(item, "upperRungId", item_context, | |||||
| &connection.upperRungId, state, | |||||
| ProjectLimits::kMaximumIdBytes) | |||||
| || !readString(item, "lowerRungId", item_context, | |||||
| &connection.lowerRungId, state, | |||||
| ProjectLimits::kMaximumIdBytes) | |||||
| || !readInt(item, "columnBoundary", item_context, 0, | |||||
| ProjectLimits::kMaximumConditionColumns, | |||||
| &boundary, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| connection.columnBoundary = boundary; | |||||
| logic->verticalConnections.push_back(std::move(connection)); | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -2,7 +2,7 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| // 严格读写当前 1.0 JSON 工程格式 | |||||
| // 严格读写当前 2.0 JSON 工程格式 | |||||
| class JsonProjectStorage final : public ProjectStorage | class JsonProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -141,7 +141,10 @@ int main(int argc, char *argv[]) | |||||
| OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository); | OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository); | ||||
| // 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期 | // 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期 | ||||
| RuntimeModeService runtime_mode_service( | RuntimeModeService runtime_mode_service( | ||||
| project_service, offline_simulation_service, online_logic_monitor_service); | |||||
| project_service, | |||||
| logic_editor_service, | |||||
| offline_simulation_service, | |||||
| online_logic_monitor_service); | |||||
| // 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 | // 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 | ||||
| runtime_mode_service.configurePlc( | runtime_mode_service.configurePlc( | ||||
| plc_communication_service, | plc_communication_service, | ||||
| @@ -201,7 +201,7 @@ bool parseInt16( | |||||
| LogicCommandResult executionFailure( | LogicCommandResult executionFailure( | ||||
| const std::string &message, LogicCommandOpcode opcode) | const std::string &message, LogicCommandOpcode opcode) | ||||
| { | { | ||||
| return {false, message, {}, {}, opcode}; | |||||
| return {false, message, {}, {}, opcode, {}, false}; | |||||
| } | } | ||||
| } // namespace | } // namespace | ||||
| @@ -425,6 +425,8 @@ LogicCommandResult LogicCommandService::execute( | |||||
| } | } | ||||
| const LogicCommandOpcode opcode = parsed.command.opcode; | const LogicCommandOpcode opcode = parsed.command.opcode; | ||||
| LogicEditorResult edited; | LogicEditorResult edited; | ||||
| LogicEditCursor next_cursor; | |||||
| bool has_next_cursor = false; | |||||
| if (request.target.kind == LogicCommandTargetKind::ExistingNode) | if (request.target.kind == LogicCommandTargetKind::ExistingNode) | ||||
| { | { | ||||
| @@ -439,7 +441,7 @@ LogicCommandResult LogicCommandService::execute( | |||||
| if (!isLoad(opcode)) | if (!isLoad(opcode)) | ||||
| { | { | ||||
| return executionFailure( | return executionFailure( | ||||
| "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); | |||||
| "已有条件节点只能替换为触点或比较指令", opcode); | |||||
| } | } | ||||
| } | } | ||||
| else if (existing->isOutput()) | else if (existing->isOutput()) | ||||
| @@ -461,12 +463,7 @@ LogicCommandResult LogicCommandService::execute( | |||||
| } | } | ||||
| else if (isCondition(opcode)) | else if (isCondition(opcode)) | ||||
| { | { | ||||
| if (isLoad(opcode) && request.continuing) | |||||
| { | |||||
| edited = editor_service_.appendCondition( | |||||
| request.logicId, {}, parsed.command.config, true); | |||||
| } | |||||
| else if (isOr(opcode)) | |||||
| if (isOr(opcode)) | |||||
| { | { | ||||
| const std::string rung_id = request.continuing | const std::string rung_id = request.continuing | ||||
| ? request.currentRungId : request.target.rungId; | ? request.currentRungId : request.target.rungId; | ||||
| @@ -489,20 +486,19 @@ LogicCommandResult LogicCommandService::execute( | |||||
| parsed.command.config, | parsed.command.config, | ||||
| true); | true); | ||||
| } | } | ||||
| } | |||||
| else if (request.continuing) | |||||
| { | |||||
| const LadderRung *rung = editor_service_.findRung( | |||||
| request.logicId, request.currentRungId); | |||||
| if (rung != nullptr && rung->output.has_value()) | |||||
| if (edited.succeeded) | |||||
| { | { | ||||
| return executionFailure( | |||||
| "当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络", | |||||
| opcode); | |||||
| const std::string edited_rung = editor_service_.rungIdForNode( | |||||
| request.logicId, edited.id); | |||||
| const int next_column = std::min( | |||||
| request.target.column + 1, | |||||
| ProjectLimits::kMaximumConditionColumns); | |||||
| next_cursor = { | |||||
| edited_rung.empty() ? rung_id : edited_rung, | |||||
| next_column, | |||||
| next_column == ProjectLimits::kMaximumConditionColumns}; | |||||
| has_next_cursor = true; | |||||
| } | } | ||||
| edited = editor_service_.appendCondition( | |||||
| request.logicId, request.currentRungId, | |||||
| parsed.command.config, true); | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| @@ -513,46 +509,35 @@ LogicCommandResult LogicCommandService::execute( | |||||
| } | } | ||||
| if ((opcode == LogicCommandOpcode::And | if ((opcode == LogicCommandOpcode::And | ||||
| || opcode == LogicCommandOpcode::AndInverse) | || opcode == LogicCommandOpcode::AndInverse) | ||||
| && (!request.target.rungId.empty() | |||||
| && (editor_service_.findRung( | |||||
| request.logicId, request.target.rungId) == nullptr | |||||
| || !editor_service_.findRung( | |||||
| request.logicId, request.target.rungId) | |||||
| ->condition.has_value()))) | |||||
| && !request.target.rungId.empty()) | |||||
| { | { | ||||
| return executionFailure( | |||||
| "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); | |||||
| } | |||||
| switch (request.target.kind) | |||||
| { | |||||
| case LogicCommandTargetKind::EmptyColumn: | |||||
| edited = editor_service_.insertConditionAtColumn( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.column, parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::BranchEmptyColumn: | |||||
| edited = editor_service_.insertConditionInBranchAtColumn( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.expressionId, request.target.column, | |||||
| parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::WireColumn: | |||||
| edited = editor_service_.replaceWireColumnWithCondition( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.expressionId, request.target.column, | |||||
| parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::GapColumn: | |||||
| edited = editor_service_.replaceGapColumnWithCondition( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.expressionId, request.target.column, | |||||
| parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::Output: | |||||
| break; | |||||
| case LogicCommandTargetKind::ExistingNode: | |||||
| break; | |||||
| const LadderRung *target_rung = editor_service_.findRung( | |||||
| request.logicId, request.target.rungId); | |||||
| const bool has_condition = target_rung != nullptr | |||||
| && std::any_of( | |||||
| target_rung->cells.cbegin(), target_rung->cells.cend(), | |||||
| [](const LadderCell &cell) | |||||
| { | |||||
| return cell.kind == LadderCellKind::Node; | |||||
| }); | |||||
| if (!has_condition) | |||||
| { | |||||
| return executionFailure( | |||||
| "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); | |||||
| } | |||||
| } | } | ||||
| const LogicEditResult applied = | |||||
| editor_service_.applyConditionAndAdvance( | |||||
| request.logicId, | |||||
| {request.continuing | |||||
| ? request.currentRungId : request.target.rungId, | |||||
| request.target.column, | |||||
| false}, | |||||
| parsed.command.config, | |||||
| true); | |||||
| edited = applied.edit; | |||||
| next_cursor = applied.nextCursor; | |||||
| has_next_cursor = edited.succeeded; | |||||
| } | } | ||||
| } | } | ||||
| else | else | ||||
| @@ -573,8 +558,14 @@ LogicCommandResult LogicCommandService::execute( | |||||
| "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", | "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", | ||||
| opcode); | opcode); | ||||
| } | } | ||||
| edited = editor_service_.setOutput( | |||||
| request.logicId, rung_id, parsed.command.config, true); | |||||
| const LogicEditResult applied = editor_service_.applyOutputAndAdvance( | |||||
| request.logicId, | |||||
| {rung_id, ProjectLimits::kMaximumConditionColumns, true}, | |||||
| parsed.command.config, | |||||
| true); | |||||
| edited = applied.edit; | |||||
| next_cursor = applied.nextCursor; | |||||
| has_next_cursor = edited.succeeded; | |||||
| } | } | ||||
| if (!edited.succeeded) | if (!edited.succeeded) | ||||
| @@ -584,5 +575,7 @@ LogicCommandResult LogicCommandService::execute( | |||||
| return { | return { | ||||
| true, {}, edited.id, | true, {}, edited.id, | ||||
| editor_service_.rungIdForNode(request.logicId, edited.id), | editor_service_.rungIdForNode(request.logicId, edited.id), | ||||
| opcode}; | |||||
| opcode, | |||||
| std::move(next_cursor), | |||||
| has_next_cursor}; | |||||
| } | } | ||||
| @@ -1,12 +1,10 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | |||||
| #include "logic_editor_service.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| class LogicEditorService; | |||||
| enum class LogicCommandOpcode | enum class LogicCommandOpcode | ||||
| { | { | ||||
| Load, | Load, | ||||
| @@ -88,6 +86,8 @@ struct LogicCommandResult | |||||
| std::string id; | std::string id; | ||||
| std::string rungId; | std::string rungId; | ||||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | LogicCommandOpcode opcode = LogicCommandOpcode::Load; | ||||
| LogicEditCursor nextCursor; | |||||
| bool hasNextCursor = false; | |||||
| }; | }; | ||||
| // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 | // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 | ||||
| @@ -9,357 +9,361 @@ | |||||
| class ProjectService; | class ProjectService; | ||||
| // 梯形图编辑失败分类 | |||||
| enum class LogicEditorError | enum class LogicEditorError | ||||
| { | { | ||||
| None, // 操作成功或没有错误 | |||||
| LogicNotFound, // 控制逻辑不存在 | |||||
| RungNotFound, // 网络不存在 | |||||
| ExpressionNotFound, // 条件表达式不存在 | |||||
| NodeNotFound, // 节点不存在 | |||||
| InvalidNode, // 节点配置或节点位置无效 | |||||
| InvalidOperation, // 操作参数或当前结构不允许该操作 | |||||
| UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改 | |||||
| DuplicateName, // 控制逻辑名称与其他逻辑重复 | |||||
| LastLogicRequired // 删除后不能少于一个控制逻辑 | |||||
| None, | |||||
| LogicNotFound, | |||||
| RungNotFound, | |||||
| CellNotFound, | |||||
| ConnectionNotFound, | |||||
| NodeNotFound, | |||||
| InvalidNode, | |||||
| InvalidOperation, | |||||
| UnsupportedNodeChange, | |||||
| DuplicateName, | |||||
| LastLogicRequired | |||||
| }; | }; | ||||
| // 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID | |||||
| struct LogicEditorResult | struct LogicEditorResult | ||||
| { | { | ||||
| bool succeeded = false; // 操作是否成功 | |||||
| LogicEditorError error = LogicEditorError::None; // 失败时的分类 | |||||
| std::string message; // 面向用户的 UTF-8 成功说明或失败原因 | |||||
| std::string id; // 成功时返回新建或更新对象的稳定 ID | |||||
| bool succeeded = false; | |||||
| LogicEditorError error = LogicEditorError::None; | |||||
| std::string message; | |||||
| std::string id; | |||||
| }; | }; | ||||
| enum class LogicConditionPasteTargetKind | |||||
| struct LogicSelectionDeleteRequest | |||||
| { | { | ||||
| Append, | |||||
| EmptyColumn, | |||||
| BranchEmptyColumn, | |||||
| AfterNode, | |||||
| ReplaceWire, | |||||
| ReplaceWireColumn, | |||||
| ReplaceGapColumn | |||||
| std::vector<std::pair<std::string, int>> cells; | |||||
| std::vector<std::string> outputRungIds; | |||||
| std::vector<std::string> verticalConnectionIds; | |||||
| }; | }; | ||||
| struct LogicConditionPasteTarget | |||||
| enum class LogicClipboardMode | |||||
| { | { | ||||
| LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append; | |||||
| std::string expressionId; | |||||
| GridObjects, | |||||
| WholeRows | |||||
| }; | |||||
| struct LogicClipboardCell | |||||
| { | |||||
| int relativeRow = 0; | |||||
| int relativeColumn = 0; | |||||
| LadderCellKind kind = LadderCellKind::Gap; | |||||
| std::optional<LogicNode> node; | |||||
| }; | |||||
| struct LogicClipboardOutput | |||||
| { | |||||
| int relativeRow = 0; | |||||
| int relativeColumn = 0; | |||||
| LogicNode node; | |||||
| }; | |||||
| struct LogicClipboardVerticalConnection | |||||
| { | |||||
| int upperRelativeRow = 0; | |||||
| int relativeColumnBoundary = 0; | |||||
| }; | |||||
| struct LogicClipboardRow | |||||
| { | |||||
| std::string comment; | |||||
| std::vector<LadderCell> cells; | |||||
| std::optional<LogicNode> output; | |||||
| }; | |||||
| // 普通片段只保存选中的对象,整行片段才保存 Gap 和网络注释 | |||||
| struct LogicClipboardFragment | |||||
| { | |||||
| LogicClipboardMode mode = LogicClipboardMode::GridObjects; | |||||
| std::vector<LogicClipboardCell> cells; | |||||
| std::vector<LogicClipboardOutput> outputs; | |||||
| std::vector<LogicClipboardVerticalConnection> verticalConnections; | |||||
| std::vector<LogicClipboardRow> rows; | |||||
| int rowSpan = 0; | |||||
| int columnSpan = 0; | |||||
| }; | |||||
| struct LogicSelectionCopyRequest | |||||
| { | |||||
| std::vector<std::pair<std::string, int>> cells; | |||||
| std::vector<std::string> outputRungIds; | |||||
| std::vector<std::string> verticalConnectionIds; | |||||
| std::vector<std::string> wholeRungIds; | |||||
| }; | |||||
| struct LogicClipboardCopyResult | |||||
| { | |||||
| LogicEditorResult copy; | |||||
| LogicClipboardFragment fragment; | |||||
| }; | |||||
| struct LogicPasteTarget | |||||
| { | |||||
| std::string rungId; | |||||
| int column = 0; | int column = 0; | ||||
| bool output = false; | |||||
| bool boundary = false; | |||||
| }; | }; | ||||
| // 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 | |||||
| struct LogicClipboardPasteResult | |||||
| { | |||||
| LogicEditorResult edit; | |||||
| LogicSelectionDeleteRequest selection; | |||||
| std::vector<std::string> wholeRungIds; | |||||
| }; | |||||
| struct LogicEditCursor | |||||
| { | |||||
| std::string rungId; | |||||
| int column = 0; | |||||
| bool output = false; | |||||
| }; | |||||
| struct LogicEditResult | |||||
| { | |||||
| LogicEditorResult edit; | |||||
| LogicEditCursor nextCursor; | |||||
| }; | |||||
| struct LogicSyntaxLocation | |||||
| { | |||||
| std::string logicId; | |||||
| std::string rungId; | |||||
| int network = 0; | |||||
| int row = 0; | |||||
| int column = 0; | |||||
| }; | |||||
| struct LogicSyntaxCheckResult | |||||
| { | |||||
| bool completed = false; | |||||
| bool valid = false; | |||||
| bool changed = false; | |||||
| std::size_t checkedLogicCount = 0U; | |||||
| std::size_t removedWireCells = 0U; | |||||
| std::size_t removedVerticalConnections = 0U; | |||||
| std::string message; | |||||
| std::optional<LogicSyntaxLocation> location; | |||||
| }; | |||||
| // 连续网格的所有修改都经此服务原子提交,并进入同一份撤销历史 | |||||
| class LogicEditorService | class LogicEditorService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建梯形图编辑服务 | |||||
| * @param project_service 用于读取和修改当前工程的项目服务 | |||||
| */ | |||||
| explicit LogicEditorService(ProjectService &project_service); | explicit LogicEditorService(ProjectService &project_service); | ||||
| // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 | |||||
| /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ | |||||
| const ControlLogic *findLogic(const std::string &logic_id) const; | const ControlLogic *findLogic(const std::string &logic_id) const; | ||||
| /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ | |||||
| const LadderRung *findRung( | const LadderRung *findRung( | ||||
| const std::string &logic_id, const std::string &rung_id) const; | const std::string &logic_id, const std::string &rung_id) const; | ||||
| /** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */ | |||||
| const LogicNode *findNode( | |||||
| const std::string &logic_id, const std::string &node_id) const; | |||||
| /** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */ | |||||
| const ConditionExpression *findExpression( | |||||
| const LadderCell *findCell( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| int column) const; | |||||
| const LadderCell *findCell( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &expression_id) const; | |||||
| /** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */ | |||||
| const std::string &cell_id) const; | |||||
| const VerticalConnection *findConnection( | |||||
| const std::string &logic_id, | |||||
| const std::string &connection_id) const; | |||||
| const LogicNode *findNode( | |||||
| const std::string &logic_id, const std::string &node_id) const; | |||||
| std::string firstLogicId() const; | std::string firstLogicId() const; | ||||
| /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ | |||||
| std::string firstRungId(const std::string &logic_id) const; | std::string firstRungId(const std::string &logic_id) const; | ||||
| /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ | |||||
| std::string rungIdForNode( | std::string rungIdForNode( | ||||
| const std::string &logic_id, const std::string &node_id) const; | const std::string &logic_id, const std::string &node_id) const; | ||||
| /** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */ | |||||
| std::string registerCommentFor(const RegisterAddress &address) const; | std::string registerCommentFor(const RegisterAddress &address) const; | ||||
| /** | |||||
| * @brief 确保工程至少有一组可编辑逻辑 | |||||
| * @return 已有或新建逻辑的成功结果及其 ID | |||||
| * | |||||
| * 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络 | |||||
| */ | |||||
| LogicEditorResult ensureDefaultLogic(); | LogicEditorResult ensureDefaultLogic(); | ||||
| /** @brief 添加控制逻辑;名称不能为空且必须唯一 */ | |||||
| LogicEditorResult addLogic(const std::string &name); | LogicEditorResult addLogic(const std::string &name); | ||||
| /** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */ | |||||
| LogicEditorResult renameLogic( | LogicEditorResult renameLogic( | ||||
| const std::string &logic_id, const std::string &name); | const std::string &logic_id, const std::string &name); | ||||
| /** @brief 删除控制逻辑;工程至少保留一组逻辑 */ | |||||
| LogicEditorResult removeLogic(const std::string &logic_id); | LogicEditorResult removeLogic(const std::string &logic_id); | ||||
| /** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */ | |||||
| LogicEditorResult moveLogic(const std::string &logic_id, int offset); | LogicEditorResult moveLogic(const std::string &logic_id, int offset); | ||||
| /** @brief 启用或停用指定控制逻辑 */ | |||||
| LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); | LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); | ||||
| /** @brief 在指定逻辑末尾添加空网络 */ | |||||
| /** 规整并检查指定控制逻辑,规整改动作为一次撤销操作 */ | |||||
| LogicSyntaxCheckResult checkSyntax(const std::string &logic_id); | |||||
| /** 单独检查指定控制逻辑中的重复 M 线圈输出 */ | |||||
| LogicSyntaxCheckResult checkDoubleCoils(const std::string &logic_id) const; | |||||
| /** 运行前规整并检查工程中全部已启用控制逻辑 */ | |||||
| LogicSyntaxCheckResult checkEnabledSyntax(); | |||||
| LogicEditorResult addRung(const std::string &logic_id); | LogicEditorResult addRung(const std::string &logic_id); | ||||
| /** @brief 删除指定网络 */ | |||||
| LogicEditorResult insertRung( | |||||
| const std::string &logic_id, | |||||
| const std::string &reference_rung_id, | |||||
| bool after); | |||||
| LogicEditorResult removeRung( | LogicEditorResult removeRung( | ||||
| const std::string &logic_id, const std::string &rung_id); | const std::string &logic_id, const std::string &rung_id); | ||||
| /** @brief 批量删除指定网络,失败时整体回滚 */ | |||||
| LogicEditorResult removeRungs( | LogicEditorResult removeRungs( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::vector<std::string> &rung_ids); | const std::vector<std::string> &rung_ids); | ||||
| /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ | |||||
| LogicEditorResult updateRungComment( | LogicEditorResult updateRungComment( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &comment); | const std::string &comment); | ||||
| // 条件区编辑:串联、按列插入、横线和并联分支 | |||||
| /** @brief 在网络条件末尾追加一个条件节点 */ | |||||
| LogicEditorResult appendCondition( | |||||
| LogicEditorResult setConditionAtColumn( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| int column, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| LogicEditResult applyConditionAndAdvance( | |||||
| const std::string &logic_id, | |||||
| const LogicEditCursor &cursor, | |||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| /** | |||||
| * @brief 按绝对条件列插入条件节点 | |||||
| * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 | |||||
| */ | |||||
| LogicEditorResult insertConditionAtColumn( | LogicEditorResult insertConditionAtColumn( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| int column, | int column, | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| /** | |||||
| * @brief 在直属并联分支的指定视觉列插入条件节点 | |||||
| * | |||||
| * 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线 | |||||
| */ | |||||
| LogicEditorResult insertConditionInBranchAtColumn( | |||||
| LogicEditorResult appendCondition( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &branch_expression_id, | |||||
| int column, | |||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| /** @brief 在网络条件末尾追加指定列宽的横线 */ | |||||
| LogicEditorResult setHorizontalWireRange( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| int first_column, | |||||
| int last_column, | |||||
| bool connected); | |||||
| LogicEditorResult appendWire( | LogicEditorResult appendWire( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| int column_span = 1); | int column_span = 1); | ||||
| /** @brief 在目标节点后串联插入条件节点 */ | |||||
| LogicEditorResult insertConditionAfter( | |||||
| LogicEditResult applyWireAndAdvance( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | |||||
| const std::string &target_node_id, | |||||
| const LogicNodeConfig &config); | |||||
| /** @brief 在目标表达式后串联插入横线 */ | |||||
| LogicEditorResult insertWireAfter( | |||||
| const LogicEditCursor &cursor); | |||||
| LogicEditorResult setWireCells( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | |||||
| const std::string &target_expression_id, | |||||
| int column_span = 1); | |||||
| /** @brief 用条件节点整体替换一条横线表达式 */ | |||||
| LogicEditorResult replaceWireWithCondition( | |||||
| const std::vector<std::pair<std::string, int>> &cells, | |||||
| bool connected); | |||||
| LogicEditorResult clearCells( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | |||||
| const std::string &wire_expression_id, | |||||
| const LogicNodeConfig &config); | |||||
| /** @brief 只替换横线表达式中的一个指定列单元格 */ | |||||
| LogicEditorResult replaceWireColumnWithCondition( | |||||
| const std::vector<std::pair<std::string, int>> &cells); | |||||
| LogicEditorResult setVerticalConnection( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | |||||
| const std::string &wire_expression_id, | |||||
| int column_offset, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ | |||||
| LogicEditorResult replaceGapColumnWithCondition( | |||||
| const std::string &upper_rung_id, | |||||
| const std::string &lower_rung_id, | |||||
| int column_boundary, | |||||
| bool connected); | |||||
| LogicEditorResult setVerticalConnectionRange( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | |||||
| const std::string &gap_expression_id, | |||||
| int column_offset, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 用一格横线修复断路表达式中的指定网格 */ | |||||
| LogicEditorResult replaceGapColumnWithWire( | |||||
| const std::string &first_rung_id, | |||||
| const std::string &last_rung_id, | |||||
| int column_boundary, | |||||
| bool connected); | |||||
| LogicEditorResult removeVerticalConnections( | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | |||||
| const std::string &gap_expression_id, | |||||
| int column_offset); | |||||
| /** | |||||
| * @brief 将选中的条件节点建立为并联分支 | |||||
| * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID | |||||
| */ | |||||
| const std::vector<std::string> &connection_ids); | |||||
| LogicEditorResult deleteSelection( | |||||
| const std::string &logic_id, | |||||
| const LogicSelectionDeleteRequest &selection); | |||||
| LogicEditorResult addParallelBranch( | LogicEditorResult addParallelBranch( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &selected_node_ids, | const std::vector<std::string> &selected_node_ids, | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| /** @brief 将当前网络的完整条件表达式与一个新条件并联 */ | |||||
| LogicEditorResult addParallelToWholeCondition( | LogicEditorResult addParallelToWholeCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| /** @brief 将选中的横线表达式建立为并联旁路 */ | |||||
| LogicEditorResult addParallelWireBranch( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &selected_expression_ids); | |||||
| /** | |||||
| * @brief 按选中的横线网格建立并联旁路 | |||||
| * | |||||
| * 所有网格必须来自同一条横线,且列号连续;操作失败时不保留部分修改 | |||||
| */ | |||||
| LogicEditorResult addParallelWireBranchAtCells( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::pair<std::string, int>> &selected_wire_cells); | |||||
| /** | |||||
| * @brief 设置网络唯一的输出节点 | |||||
| * @param configured 是否将新输出标记为已完成配置 | |||||
| * | |||||
| * 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑 | |||||
| */ | |||||
| LogicEditorResult setOutput( | LogicEditorResult setOutput( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| // 节点属性和删除操作 | |||||
| /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ | |||||
| LogicEditResult applyOutputAndAdvance( | |||||
| const std::string &logic_id, | |||||
| const LogicEditCursor &cursor, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| LogicEditorResult updateNodeConfig( | LogicEditorResult updateNodeConfig( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &node_id, | const std::string &node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| /** | |||||
| * @brief 批量粘贴条件节点到目标网络末尾 | |||||
| * @param logic_id 目标控制逻辑 | |||||
| * @param rung_id 目标网络;为空时自动创建网络 | |||||
| * @param nodes 待复制节点,只读取配置和 configured 状态 | |||||
| * @return 成功时返回第一个新节点 ID,失败时整批回滚 | |||||
| */ | |||||
| LogicClipboardCopyResult copySelection( | |||||
| const std::string &logic_id, | |||||
| const LogicSelectionCopyRequest &selection) const; | |||||
| LogicClipboardPasteResult pasteClipboard( | |||||
| const std::string &logic_id, | |||||
| const LogicClipboardFragment &fragment, | |||||
| const LogicPasteTarget &target); | |||||
| LogicEditorResult pasteConditionNodes( | LogicEditorResult pasteConditionNodes( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<LogicNode> &nodes, | const std::vector<LogicNode> &nodes, | ||||
| const LogicConditionPasteTarget &target = {}); | |||||
| /** 判断所选条件是否位于同一串联层级并且视觉连续 */ | |||||
| int start_column = -1); | |||||
| bool areConditionNodesContiguous( | bool areConditionNodesContiguous( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &node_ids) const; | const std::vector<std::string> &node_ids) const; | ||||
| /** | |||||
| * @brief 将整条网络复制到目标控制逻辑末尾 | |||||
| * @param logic_id 目标控制逻辑 | |||||
| * @param source 要复制的网络 | |||||
| * @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID | |||||
| */ | |||||
| LogicEditorResult pasteRung( | LogicEditorResult pasteRung( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const LadderRung &source); | const LadderRung &source); | ||||
| /** @brief 删除指定节点并归一化受影响的表达式树 */ | |||||
| LogicEditorResult removeNode( | LogicEditorResult removeNode( | ||||
| const std::string &logic_id, const std::string &node_id); | const std::string &logic_id, const std::string &node_id); | ||||
| /** | |||||
| * @brief 批量删除节点 | |||||
| * @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID | |||||
| * @return 成功时作为一次编辑记录,失败时整体回滚 | |||||
| */ | |||||
| LogicEditorResult removeNodes( | LogicEditorResult removeNodes( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::vector<std::string> &node_ids); | const std::vector<std::string> &node_ids); | ||||
| /** @brief 删除指定条件表达式并归一化表达式树 */ | |||||
| LogicEditorResult removeExpression( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &expression_id); | |||||
| /** @brief 批量删除条件表达式,失败时整体回滚 */ | |||||
| LogicEditorResult removeExpressions( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &expression_ids); | |||||
| /** @brief 把选中的横线网格替换为断路,保留网络列位置 */ | |||||
| LogicEditorResult disconnectWireCells( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::pair<std::string, int>> &wire_cells); | |||||
| /** @brief 把整段横线替换为等宽断路 */ | |||||
| LogicEditorResult disconnectWires( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &wire_ids); | |||||
| // 当前编辑会话的撤销/重做 | |||||
| /** @brief 判断是否存在可撤销的梯形图编辑操作 */ | |||||
| bool canUndo() const; | bool canUndo() const; | ||||
| /** @brief 判断是否存在可重做的梯形图编辑操作 */ | |||||
| bool canRedo() const; | bool canRedo() const; | ||||
| /** @brief 撤销最近一次成功的梯形图编辑操作 */ | |||||
| LogicEditorResult undo(); | LogicEditorResult undo(); | ||||
| /** @brief 重做最近一次被撤销的梯形图编辑操作 */ | |||||
| LogicEditorResult redo(); | LogicEditorResult redo(); | ||||
| /** @brief 清空撤销/重做历史,不修改当前工程内容 */ | |||||
| void clearHistory(); | void clearHistory(); | ||||
| private: | private: | ||||
| // 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史 | |||||
| struct HistoryState | struct HistoryState | ||||
| { | { | ||||
| std::vector<ControlLogic> logics; // 编辑前或编辑后的完整逻辑集合 | |||||
| std::vector<ControlLogic> logics; | |||||
| }; | }; | ||||
| // 捕获当前工程中的全部控制逻辑 | |||||
| HistoryState captureState() const; | HistoryState captureState() const; | ||||
| // 比较编辑前后状态并记录实际发生的修改 | |||||
| void recordHistory(HistoryState before); | void recordHistory(HistoryState before); | ||||
| // 失败时恢复工程内容和操作前脏标记,保证编辑原子性 | |||||
| void rollbackEdit(HistoryState before, bool modified_before); | void rollbackEdit(HistoryState before, bool modified_before); | ||||
| // 比较两个历史快照是否完全相同 | |||||
| static bool statesEqual( | static bool statesEqual( | ||||
| const HistoryState &left, const HistoryState &right); | const HistoryState &left, const HistoryState &right); | ||||
| static bool logicsEqual( | static bool logicsEqual( | ||||
| const ControlLogic &left, const ControlLogic &right); | const ControlLogic &left, const ControlLogic &right); | ||||
| static bool rungsEqual( | static bool rungsEqual( | ||||
| const LadderRung &left, const LadderRung &right); | const LadderRung &left, const LadderRung &right); | ||||
| static bool expressionsEqual( | |||||
| const ConditionExpression &left, const ConditionExpression &right); | |||||
| static bool cellsEqual( | |||||
| const LadderCell &left, const LadderCell &right); | |||||
| static bool nodesEqual(const LogicNode &left, const LogicNode &right); | static bool nodesEqual(const LogicNode &left, const LogicNode &right); | ||||
| static bool configsEqual( | static bool configsEqual( | ||||
| const LogicNodeConfig &left, const LogicNodeConfig &right); | const LogicNodeConfig &left, const LogicNodeConfig &right); | ||||
| static LogicEditorResult historyFailure(const std::string &message); | static LogicEditorResult historyFailure(const std::string &message); | ||||
| // 判断节点配置是否属于条件节点 | |||||
| static bool isConditionConfig(const LogicNodeConfig &config); | static bool isConditionConfig(const LogicNodeConfig &config); | ||||
| // 判断节点配置是否属于输出节点 | |||||
| static bool isOutputConfig(const LogicNodeConfig &config); | static bool isOutputConfig(const LogicNodeConfig &config); | ||||
| // 返回节点配置对应的稳定 ID 前缀 | |||||
| static std::string nodePrefix(const LogicNodeConfig &config); | static std::string nodePrefix(const LogicNodeConfig &config); | ||||
| // 在一个控制逻辑的全部网络中生成唯一节点 ID | |||||
| static std::string makeUniqueNodeId( | |||||
| static std::string makeUniqueId( | |||||
| const ControlLogic &logic, const std::string &prefix); | const ControlLogic &logic, const std::string &prefix); | ||||
| // 生成唯一横线表达式 ID | |||||
| static std::string makeUniqueWireId(const ControlLogic &logic); | |||||
| // 生成控制逻辑内唯一的断路表达式 ID | |||||
| static std::string makeUniqueGapId(const ControlLogic &logic); | |||||
| // 生成唯一容器表达式 ID | |||||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | |||||
| // 生成唯一网络 ID | |||||
| static std::string makeUniqueRungId(const ControlLogic &logic); | |||||
| // 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局 | |||||
| static bool repairExplicitLayout( | |||||
| ControlLogic &logic, LadderRung &rung, std::string *error = nullptr); | |||||
| // 创建统一的失败结果 | |||||
| static LadderRung makeEmptyRung( | |||||
| const ControlLogic &logic, std::size_t visual_index); | |||||
| static std::string insertEmptyRungAt( | |||||
| ControlLogic *logic, std::size_t position); | |||||
| static void removeRungAt(ControlLogic *logic, std::size_t position); | |||||
| static void refreshRungNames(ControlLogic *logic); | |||||
| static LogicEditorResult failure( | static LogicEditorResult failure( | ||||
| LogicEditorError error, const std::string &message); | LogicEditorError error, const std::string &message); | ||||
| LogicEditResult applyCellAndAdvance( | |||||
| const std::string &logic_id, | |||||
| const LogicEditCursor &cursor, | |||||
| const LogicNodeConfig *config, | |||||
| bool configured); | |||||
| LogicSyntaxCheckResult checkSyntaxForLogics( | |||||
| const std::vector<std::string> &logic_ids); | |||||
| ProjectService &project_service_; // 不拥有的工程服务依赖 | |||||
| EditorHistory<HistoryState> history_; // 当前编辑会话的撤销/重做历史 | |||||
| bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史 | |||||
| ProjectService &project_service_; | |||||
| EditorHistory<HistoryState> history_; | |||||
| }; | }; | ||||
| @@ -59,10 +59,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||||
| for (const LadderRung &rung : logic.rungs) | for (const LadderRung &rung : logic.rungs) | ||||
| { | { | ||||
| std::vector<const LogicNode *> nodes; | std::vector<const LogicNode *> nodes; | ||||
| if (rung.condition.has_value()) | |||||
| { | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| } | |||||
| collectConditionNodes(rung, &nodes); | |||||
| if (rung.output.has_value()) | if (rung.output.has_value()) | ||||
| { | { | ||||
| nodes.push_back(&*rung.output); | nodes.push_back(&*rung.output); | ||||
| @@ -202,7 +202,7 @@ Project ProjectService::makeNewProject(const std::string &name) | |||||
| project.metadata.id = generateProjectId(); | project.metadata.id = generateProjectId(); | ||||
| project.metadata.name = name; | project.metadata.name = name; | ||||
| // 新工程固定使用当前存储格式版本 | // 新工程固定使用当前存储格式版本 | ||||
| project.metadata.formatVersion = "1.0"; | |||||
| project.metadata.formatVersion = "2.0"; | |||||
| return project; | return project; | ||||
| } | } | ||||
| @@ -13,12 +13,15 @@ | |||||
| #include "domain/project_limits.h" | #include "domain/project_limits.h" | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <utility> | |||||
| RuntimeModeService::RuntimeModeService( | RuntimeModeService::RuntimeModeService( | ||||
| const ProjectService &project_service, | const ProjectService &project_service, | ||||
| LogicEditorService &logic_editor_service, | |||||
| OfflineSimulationService &offline_simulation_service, | OfflineSimulationService &offline_simulation_service, | ||||
| OnlineLogicMonitorService &online_logic_monitor_service) | OnlineLogicMonitorService &online_logic_monitor_service) | ||||
| : project_service_(project_service), | : project_service_(project_service), | ||||
| logic_editor_service_(logic_editor_service), | |||||
| offline_simulation_service_(offline_simulation_service), | offline_simulation_service_(offline_simulation_service), | ||||
| online_logic_monitor_service_(online_logic_monitor_service) | online_logic_monitor_service_(online_logic_monitor_service) | ||||
| { | { | ||||
| @@ -68,11 +71,10 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||||
| { | { | ||||
| return state_.enterOfflineRunning(); | return state_.enterOfflineRunning(); | ||||
| } | } | ||||
| std::string error; | |||||
| if (!project_service_.project().validateForRunning( | |||||
| project_service_.projectLimits(), &error)) | |||||
| const ModeTransitionResult preparation = prepareProjectForRunning(); | |||||
| if (!preparation.succeeded) | |||||
| { | { | ||||
| return {false, ModeTransitionError::ProjectNotReady}; | |||||
| return preparation; | |||||
| } | } | ||||
| // 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存 | // 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存 | ||||
| if (active_repository_ != nullptr && virtual_repository_ != nullptr) | if (active_repository_ != nullptr && virtual_repository_ != nullptr) | ||||
| @@ -84,7 +86,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||||
| project_service_.project().controlLogics); | project_service_.project().controlLogics); | ||||
| if (!start_result.succeeded) | if (!start_result.succeeded) | ||||
| { | { | ||||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||||
| return {false, ModeTransitionError::SimulationStartFailed, {}}; | |||||
| } | } | ||||
| const ModeTransitionResult transition = state_.enterOfflineRunning(); | const ModeTransitionResult transition = state_.enterOfflineRunning(); | ||||
| if (!transition.succeeded) | if (!transition.succeeded) | ||||
| @@ -104,12 +106,17 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning() | |||||
| { | { | ||||
| return state_.enterOnlineRunning(false); | return state_.enterOnlineRunning(false); | ||||
| } | } | ||||
| const ModeTransitionResult preparation = prepareProjectForRunning(); | |||||
| if (!preparation.succeeded) | |||||
| { | |||||
| return preparation; | |||||
| } | |||||
| const OnlineLogicMonitorStartResult start_result = | const OnlineLogicMonitorStartResult start_result = | ||||
| online_logic_monitor_service_.start( | online_logic_monitor_service_.start( | ||||
| project_service_.project().controlLogics); | project_service_.project().controlLogics); | ||||
| if (!start_result.succeeded) | if (!start_result.succeeded) | ||||
| { | { | ||||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||||
| return {false, ModeTransitionError::SimulationStartFailed, {}}; | |||||
| } | } | ||||
| const ModeTransitionResult result = state_.enterOnlineRunning(true); | const ModeTransitionResult result = state_.enterOnlineRunning(true); | ||||
| if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) | if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) | ||||
| @@ -162,6 +169,30 @@ OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService() | |||||
| return online_logic_monitor_service_; | return online_logic_monitor_service_; | ||||
| } | } | ||||
| const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const | |||||
| { | |||||
| return last_syntax_check_; | |||||
| } | |||||
| ModeTransitionResult RuntimeModeService::prepareProjectForRunning() | |||||
| { | |||||
| last_syntax_check_ = logic_editor_service_.checkEnabledSyntax(); | |||||
| if (!last_syntax_check_.completed || !last_syntax_check_.valid) | |||||
| { | |||||
| return { | |||||
| false, | |||||
| ModeTransitionError::ProjectNotReady, | |||||
| last_syntax_check_.message}; | |||||
| } | |||||
| std::string error; | |||||
| if (!project_service_.project().validateForRunning( | |||||
| project_service_.projectLimits(), &error)) | |||||
| { | |||||
| return {false, ModeTransitionError::ProjectNotReady, std::move(error)}; | |||||
| } | |||||
| return {true, ModeTransitionError::None, {}}; | |||||
| } | |||||
| void RuntimeModeService::configurePlc( | void RuntimeModeService::configurePlc( | ||||
| PlcCommunicationGateway &gateway, | PlcCommunicationGateway &gateway, | ||||
| ActiveRegisterRepository &active_repository, | ActiveRegisterRepository &active_repository, | ||||
| @@ -304,10 +335,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| for (const LadderRung &rung : logic.rungs) | for (const LadderRung &rung : logic.rungs) | ||||
| { | { | ||||
| std::vector<const LogicNode *> nodes; | std::vector<const LogicNode *> nodes; | ||||
| if (rung.condition.has_value()) | |||||
| { | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| } | |||||
| collectConditionNodes(rung, &nodes); | |||||
| if (rung.output.has_value()) | if (rung.output.has_value()) | ||||
| { | { | ||||
| nodes.push_back(&*rung.output); | nodes.push_back(&*rung.output); | ||||
| @@ -9,6 +9,7 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "logic_editor_service.h" | |||||
| #include "offline_simulation_service.h" | #include "offline_simulation_service.h" | ||||
| #include "online_logic_monitor_service.h" | #include "online_logic_monitor_service.h" | ||||
| #include "plc_communication_gateway.h" | #include "plc_communication_gateway.h" | ||||
| @@ -32,6 +33,7 @@ public: | |||||
| */ | */ | ||||
| RuntimeModeService( | RuntimeModeService( | ||||
| const ProjectService &project_service, | const ProjectService &project_service, | ||||
| LogicEditorService &logic_editor_service, | |||||
| OfflineSimulationService &offline_simulation_service, | OfflineSimulationService &offline_simulation_service, | ||||
| OnlineLogicMonitorService &online_logic_monitor_service); | OnlineLogicMonitorService &online_logic_monitor_service); | ||||
| @@ -86,6 +88,8 @@ public: | |||||
| /** @brief 返回服务持有的离线仿真服务引用 */ | /** @brief 返回服务持有的离线仿真服务引用 */ | ||||
| OfflineSimulationService &offlineSimulationService(); | OfflineSimulationService &offlineSimulationService(); | ||||
| OnlineLogicMonitorService &onlineLogicMonitorService(); | OnlineLogicMonitorService &onlineLogicMonitorService(); | ||||
| /** 返回最近一次进入运行模式前执行的梯形图语法检查结果 */ | |||||
| const LogicSyntaxCheckResult &lastSyntaxCheck() const; | |||||
| /** | /** | ||||
| * @brief 注入 PLC 网关、活动仓库和两种实际数据源 | * @brief 注入 PLC 网关、活动仓库和两种实际数据源 | ||||
| @@ -137,7 +141,10 @@ public: | |||||
| void setPlcStatusChangedCallback(std::function<void()> callback); | void setPlcStatusChangedCallback(std::function<void()> callback); | ||||
| private: | private: | ||||
| ModeTransitionResult prepareProjectForRunning(); | |||||
| const ProjectService &project_service_; // 不拥有的只读工程服务 | const ProjectService &project_service_; // 不拥有的只读工程服务 | ||||
| LogicEditorService &logic_editor_service_; // 统一执行运行前规整和语法检查 | |||||
| OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 | OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 | ||||
| OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务 | OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务 | ||||
| RuntimeState state_; // 编辑、离线和真机模式状态机 | RuntimeState state_; // 编辑、离线和真机模式状态机 | ||||
| @@ -150,4 +157,5 @@ private: | |||||
| std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知 | std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知 | ||||
| std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址 | std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址 | ||||
| std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址 | std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址 | ||||
| LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 | |||||
| }; | }; | ||||
| @@ -75,9 +75,10 @@ void LogicTraceValues::clear() | |||||
| { | { | ||||
| nodeValues.clear(); | nodeValues.clear(); | ||||
| nodePowerValues.clear(); | nodePowerValues.clear(); | ||||
| expressionValues.clear(); | |||||
| expressionInputValues.clear(); | |||||
| expressionPowerValues.clear(); | |||||
| cellValues.clear(); | |||||
| cellInputPowerValues.clear(); | |||||
| cellPowerValues.clear(); | |||||
| verticalConnectionValues.clear(); | |||||
| rungValues.clear(); | rungValues.clear(); | ||||
| wordValues.clear(); | wordValues.clear(); | ||||
| } | } | ||||
| @@ -97,9 +98,11 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||||
| { | { | ||||
| projection.nodeValues = values->second.nodeValues; | projection.nodeValues = values->second.nodeValues; | ||||
| projection.nodePowerValues = values->second.nodePowerValues; | projection.nodePowerValues = values->second.nodePowerValues; | ||||
| projection.expressionValues = values->second.expressionValues; | |||||
| projection.expressionInputValues = values->second.expressionInputValues; | |||||
| projection.expressionPowerValues = values->second.expressionPowerValues; | |||||
| projection.cellValues = values->second.cellValues; | |||||
| projection.cellInputPowerValues = values->second.cellInputPowerValues; | |||||
| projection.cellPowerValues = values->second.cellPowerValues; | |||||
| projection.verticalConnectionValues = | |||||
| values->second.verticalConnectionValues; | |||||
| projection.rungValues = values->second.rungValues; | projection.rungValues = values->second.rungValues; | ||||
| projection.wordValues = values->second.wordValues; | projection.wordValues = values->second.wordValues; | ||||
| } | } | ||||
| @@ -192,7 +195,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| { | { | ||||
| trace->clear(); | trace->clear(); | ||||
| } | } | ||||
| // 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见 | |||||
| // 互相没有竖线连接的行仍按先后顺序扫描,保持原有网络间可见性 | |||||
| for (const ControlLogic &logic : logics) | for (const ControlLogic &logic : logics) | ||||
| { | { | ||||
| if (!logic.enabled) | if (!logic.enabled) | ||||
| @@ -201,56 +204,195 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| } | } | ||||
| LogicTraceValues *logic_trace = trace == nullptr | LogicTraceValues *logic_trace = trace == nullptr | ||||
| ? nullptr : &trace->logicValues[logic.id]; | ? nullptr : &trace->logicValues[logic.id]; | ||||
| for (const LadderRung &rung : logic.rungs) | |||||
| std::size_t group_start = 0U; | |||||
| while (group_start < logic.rungs.size()) | |||||
| { | { | ||||
| if (!rung.condition.has_value() && !rung.output.has_value()) | |||||
| std::size_t group_end = group_start; | |||||
| while (group_end + 1U < logic.rungs.size()) | |||||
| { | { | ||||
| continue; | |||||
| const std::string &upper_id = logic.rungs[group_end].id; | |||||
| const std::string &lower_id = logic.rungs[group_end + 1U].id; | |||||
| const bool connected = std::any_of( | |||||
| logic.verticalConnections.cbegin(), | |||||
| logic.verticalConnections.cend(), | |||||
| [&upper_id, &lower_id](const VerticalConnection &connection) | |||||
| { | |||||
| return connection.upperRungId == upper_id | |||||
| && connection.lowerRungId == lower_id; | |||||
| }); | |||||
| if (!connected) | |||||
| { | |||||
| break; | |||||
| } | |||||
| ++group_end; | |||||
| } | } | ||||
| // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 | |||||
| bool rung_value = true; | |||||
| LogicScanResult result = success(); | |||||
| if (rung.condition.has_value()) | |||||
| { | |||||
| result = evaluateExpression( | |||||
| logic.id, | |||||
| *rung.condition, | |||||
| repository, | |||||
| logic_trace, | |||||
| true, | |||||
| &rung_value); | |||||
| } | |||||
| if (!result.succeeded) | |||||
| { | |||||
| result.logicId = logic.id; | |||||
| result.rungId = rung.id; | |||||
| return result; | |||||
| } | |||||
| if (logic_trace != nullptr) | |||||
| const std::size_t group_size = group_end - group_start + 1U; | |||||
| std::vector<bool> power(group_size, true); | |||||
| for (int boundary = 0; | |||||
| boundary <= ProjectLimits::kMaximumConditionColumns; | |||||
| ++boundary) | |||||
| { | { | ||||
| logic_trace->rungValues[rung.id] = rung_value; | |||||
| logic_trace->nodeValues[rung.output->id] = rung_value; | |||||
| logic_trace->nodePowerValues[rung.output->id] = rung_value; | |||||
| } | |||||
| bool output_value = false; | |||||
| result = executeOutput( | |||||
| *rung.output, | |||||
| rung_value, | |||||
| repository, | |||||
| logic_trace, | |||||
| &output_value); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| result.logicId = logic.id; | |||||
| result.rungId = rung.id; | |||||
| return result; | |||||
| std::vector<std::size_t> parent(group_size); | |||||
| for (std::size_t index = 0U; index < group_size; ++index) | |||||
| { | |||||
| parent[index] = index; | |||||
| } | |||||
| const auto root = [&parent](std::size_t index) | |||||
| { | |||||
| while (parent[index] != index) | |||||
| { | |||||
| index = parent[index]; | |||||
| } | |||||
| return index; | |||||
| }; | |||||
| const auto unite = [&parent, &root]( | |||||
| std::size_t left, std::size_t right) | |||||
| { | |||||
| const std::size_t left_root = root(left); | |||||
| const std::size_t right_root = root(right); | |||||
| if (left_root != right_root) | |||||
| { | |||||
| parent[right_root] = left_root; | |||||
| } | |||||
| }; | |||||
| for (const VerticalConnection &connection | |||||
| : logic.verticalConnections) | |||||
| { | |||||
| if (connection.columnBoundary != boundary) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (std::size_t row = group_start; | |||||
| row < group_end; | |||||
| ++row) | |||||
| { | |||||
| if (logic.rungs[row].id == connection.upperRungId | |||||
| && logic.rungs[row + 1U].id | |||||
| == connection.lowerRungId) | |||||
| { | |||||
| unite(row - group_start, row + 1U - group_start); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| std::vector<bool> component_power(group_size, false); | |||||
| for (std::size_t index = 0U; index < group_size; ++index) | |||||
| { | |||||
| component_power[root(index)] = | |||||
| component_power[root(index)] || power[index]; | |||||
| } | |||||
| for (std::size_t index = 0U; index < group_size; ++index) | |||||
| { | |||||
| power[index] = component_power[root(index)]; | |||||
| } | |||||
| if (logic_trace != nullptr) | |||||
| { | |||||
| for (const VerticalConnection &connection | |||||
| : logic.verticalConnections) | |||||
| { | |||||
| if (connection.columnBoundary != boundary) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (std::size_t row = group_start; | |||||
| row < group_end; | |||||
| ++row) | |||||
| { | |||||
| if (logic.rungs[row].id == connection.upperRungId | |||||
| && logic.rungs[row + 1U].id | |||||
| == connection.lowerRungId) | |||||
| { | |||||
| logic_trace->verticalConnectionValues[ | |||||
| connection.id] = power[row - group_start]; | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| if (boundary == ProjectLimits::kMaximumConditionColumns) | |||||
| { | |||||
| break; | |||||
| } | |||||
| for (std::size_t local_row = 0U; | |||||
| local_row < group_size; | |||||
| ++local_row) | |||||
| { | |||||
| const LadderRung &rung = | |||||
| logic.rungs[group_start + local_row]; | |||||
| const LadderCell &cell = rung.cells[ | |||||
| static_cast<std::size_t>(boundary)]; | |||||
| const bool input_power = power[local_row]; | |||||
| bool cell_value = cell.kind == LadderCellKind::Wire; | |||||
| LogicScanResult result = success(); | |||||
| if (cell.kind == LadderCellKind::Node) | |||||
| { | |||||
| result = evaluateCondition( | |||||
| logic.id, | |||||
| *cell.node, | |||||
| repository, | |||||
| &cell_value); | |||||
| } | |||||
| if (!result.succeeded) | |||||
| { | |||||
| result.logicId = logic.id; | |||||
| result.rungId = rung.id; | |||||
| return result; | |||||
| } | |||||
| power[local_row] = input_power && cell_value; | |||||
| if (logic_trace != nullptr) | |||||
| { | |||||
| logic_trace->cellValues[cell.id] = cell_value; | |||||
| logic_trace->cellInputPowerValues[cell.id] = input_power; | |||||
| logic_trace->cellPowerValues[cell.id] = power[local_row]; | |||||
| if (cell.node.has_value()) | |||||
| { | |||||
| logic_trace->nodeValues[cell.node->id] = cell_value; | |||||
| logic_trace->nodePowerValues[cell.node->id] = | |||||
| power[local_row]; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | } | ||||
| if (logic_trace != nullptr) | |||||
| // 同一个竖线连通组先完成条件传播,再按视觉行顺序执行输出 | |||||
| for (std::size_t local_row = 0U; | |||||
| local_row < group_size; | |||||
| ++local_row) | |||||
| { | { | ||||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||||
| logic_trace->nodePowerValues[rung.output->id] = output_value; | |||||
| const LadderRung &rung = logic.rungs[group_start + local_row]; | |||||
| const bool rung_value = power[local_row]; | |||||
| if (logic_trace != nullptr) | |||||
| { | |||||
| logic_trace->rungValues[rung.id] = rung_value; | |||||
| } | |||||
| if (!rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| bool output_value = false; | |||||
| LogicScanResult result = executeOutput( | |||||
| *rung.output, | |||||
| rung_value, | |||||
| repository, | |||||
| logic_trace, | |||||
| &output_value); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| result.logicId = logic.id; | |||||
| result.rungId = rung.id; | |||||
| return result; | |||||
| } | |||||
| if (logic_trace != nullptr) | |||||
| { | |||||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||||
| logic_trace->nodePowerValues[rung.output->id] = rung_value; | |||||
| } | |||||
| } | } | ||||
| group_start = group_end + 1U; | |||||
| } | } | ||||
| } | } | ||||
| if (trace != nullptr) | if (trace != nullptr) | ||||
| @@ -265,9 +407,11 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| { | { | ||||
| trace->nodeValues = values->second.nodeValues; | trace->nodeValues = values->second.nodeValues; | ||||
| trace->nodePowerValues = values->second.nodePowerValues; | trace->nodePowerValues = values->second.nodePowerValues; | ||||
| trace->expressionValues = values->second.expressionValues; | |||||
| trace->expressionInputValues = values->second.expressionInputValues; | |||||
| trace->expressionPowerValues = values->second.expressionPowerValues; | |||||
| trace->cellValues = values->second.cellValues; | |||||
| trace->cellInputPowerValues = values->second.cellInputPowerValues; | |||||
| trace->cellPowerValues = values->second.cellPowerValues; | |||||
| trace->verticalConnectionValues = | |||||
| values->second.verticalConnectionValues; | |||||
| trace->rungValues = values->second.rungValues; | trace->rungValues = values->second.rungValues; | ||||
| trace->wordValues = values->second.wordValues; | trace->wordValues = values->second.wordValues; | ||||
| } | } | ||||
| @@ -276,89 +420,6 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| return success(); | return success(); | ||||
| } | } | ||||
| LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||||
| const std::string &logic_id, | |||||
| const ConditionExpression &expression, | |||||
| RegisterRepository &repository, | |||||
| LogicTraceValues *trace, | |||||
| bool input_power, | |||||
| bool *value) | |||||
| { | |||||
| if (value == nullptr) | |||||
| { | |||||
| return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象"); | |||||
| } | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->expressionInputValues[expression.id] = input_power; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| *value = true; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->expressionValues[expression.id] = true; | |||||
| trace->expressionPowerValues[expression.id] = input_power; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "梯形图条件区存在未连接的空白网格", | |||||
| logic_id, | |||||
| {}, | |||||
| expression.id); | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| LogicScanResult result = evaluateCondition( | |||||
| logic_id, *expression.node, repository, value); | |||||
| if (result.succeeded && trace != nullptr) | |||||
| { | |||||
| trace->nodeValues[expression.node->id] = *value; | |||||
| trace->nodePowerValues[expression.node->id] = input_power && *value; | |||||
| trace->expressionValues[expression.id] = *value; | |||||
| trace->expressionPowerValues[expression.id] = input_power && *value; | |||||
| } | |||||
| return result; | |||||
| } | |||||
| bool accumulated = expression.kind == ConditionExpressionKind::Series; | |||||
| bool power = input_power; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | |||||
| bool child_value = false; | |||||
| const bool child_input = expression.kind == ConditionExpressionKind::Series | |||||
| ? power : input_power; | |||||
| LogicScanResult result = evaluateExpression( | |||||
| logic_id, | |||||
| child, | |||||
| repository, | |||||
| trace, | |||||
| child_input, | |||||
| &child_value); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return result; | |||||
| } | |||||
| accumulated = expression.kind == ConditionExpressionKind::Series | |||||
| ? accumulated && child_value : accumulated || child_value; | |||||
| if (expression.kind == ConditionExpressionKind::Series) | |||||
| { | |||||
| power = power && child_value; | |||||
| } | |||||
| } | |||||
| *value = accumulated; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->expressionValues[expression.id] = accumulated; | |||||
| trace->expressionPowerValues[expression.id] = input_power && accumulated; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::evaluateCondition( | LogicScanResult SoftwareLogicExecutor::evaluateCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const LogicNode &node, | const LogicNode &node, | ||||
| @@ -38,15 +38,16 @@ struct WordTraceValue | |||||
| bool overflow = false; // ADD/SUB 是否发生饱和溢出 | bool overflow = false; // ADD/SUB 是否发生饱和溢出 | ||||
| }; | }; | ||||
| // 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键 | |||||
| // 一张连续梯形图的运行轨迹快照 | |||||
| struct LogicTraceValues | struct LogicTraceValues | ||||
| { | { | ||||
| std::unordered_map<std::string, bool> nodeValues; // 节点最终逻辑值 | |||||
| std::unordered_map<std::string, bool> nodePowerValues; // 节点输入电源状态 | |||||
| std::unordered_map<std::string, bool> expressionValues; // 条件表达式最终值 | |||||
| std::unordered_map<std::string, bool> expressionInputValues; // 表达式输入状态 | |||||
| std::unordered_map<std::string, bool> expressionPowerValues; // 表达式输出电源状态 | |||||
| std::unordered_map<std::string, bool> rungValues; // 网络最终逻辑值 | |||||
| std::unordered_map<std::string, bool> nodeValues; // 节点自身结果 | |||||
| std::unordered_map<std::string, bool> nodePowerValues; // 节点后的带电状态 | |||||
| std::unordered_map<std::string, bool> cellValues; // 网格自身是否导通 | |||||
| std::unordered_map<std::string, bool> cellInputPowerValues; // 网格左边界电源 | |||||
| std::unordered_map<std::string, bool> cellPowerValues; // 网格右边界电源 | |||||
| std::unordered_map<std::string, bool> verticalConnectionValues; // 竖线所在边界电源 | |||||
| std::unordered_map<std::string, bool> rungValues; // 行末输出槽电源 | |||||
| std::unordered_map<std::string, WordTraceValue> wordValues; // MOVE/ADD/SUB 节点状态 | std::unordered_map<std::string, WordTraceValue> wordValues; // MOVE/ADD/SUB 节点状态 | ||||
| // 清除当前逻辑或网络的全部轨迹 | // 清除当前逻辑或网络的全部轨迹 | ||||
| @@ -109,23 +110,6 @@ private: | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| bool *value); | bool *value); | ||||
| /** | |||||
| * @brief 递归计算串联、并联或叶子条件表达式 | |||||
| * @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算 | |||||
| * @param expression 待读取的条件表达式树 | |||||
| * @param repository 提供 M/D 值的寄存器仓库 | |||||
| * @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出 | |||||
| * @param input_power 进入当前表达式的电源状态 | |||||
| * @param value 输出表达式最终值,不能为空 | |||||
| * @return 成功结果,或包含失败节点 ID 的扫描错误 | |||||
| */ | |||||
| LogicScanResult evaluateExpression( | |||||
| const std::string &logic_id, | |||||
| const ConditionExpression &expression, | |||||
| RegisterRepository &repository, | |||||
| LogicTraceValues *trace, | |||||
| bool input_power, | |||||
| bool *value); | |||||
| /** | /** | ||||
| * @brief 在网络结果驱动下执行单个输出节点 | * @brief 在网络结果驱动下执行单个输出节点 | ||||
| * @param node 待读取配置并执行的输出节点 | * @param node 待读取配置并执行的输出节点 | ||||
| @@ -1,11 +1,3 @@ | |||||
| /** | |||||
| * @file logic_editor_widget.h | |||||
| * @brief 定义结构化梯形图编辑和运行轨迹画布 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| @@ -14,6 +6,7 @@ | |||||
| #include "services/software_logic_executor.h" | #include "services/software_logic_executor.h" | ||||
| #include <QGraphicsView> | #include <QGraphicsView> | ||||
| #include <QPoint> | |||||
| #include <string> | #include <string> | ||||
| #include <utility> | #include <utility> | ||||
| @@ -27,180 +20,149 @@ class QMouseEvent; | |||||
| class QRubberBand; | class QRubberBand; | ||||
| class QResizeEvent; | class QResizeEvent; | ||||
| /** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ | |||||
| class LogicEditorWidget final : public QGraphicsView | class LogicEditorWidget final : public QGraphicsView | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 梯形图中的条件、输出、横线和辅助选择图元类型 */ | |||||
| class NodeItem; | |||||
| class WireItem; | |||||
| class WireCellItem; | |||||
| class GapCellItem; | |||||
| class VerticalConnectorItem; | |||||
| class RungItem; | |||||
| class EmptySlotItem; | |||||
| class OutputSlotItem; | |||||
| enum class MouseWireMode { Select, Draw, Erase }; | |||||
| /** 创建梯形图画布并绑定编辑服务 */ | |||||
| explicit LogicEditorWidget( | explicit LogicEditorWidget( | ||||
| LogicEditorService &editor_service, | LogicEditorService &editor_service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 切换当前显示的控制逻辑;不存在时显示空场景 */ | |||||
| void setLogicId(const std::string &logic_id); | void setLogicId(const std::string &logic_id); | ||||
| /** 设置是否允许编辑梯形图 */ | |||||
| void setEditingEnabled(bool enabled); | void setEditingEnabled(bool enabled); | ||||
| /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ | |||||
| void setMouseWireMode(MouseWireMode mode); | |||||
| MouseWireMode mouseWireMode() const; | |||||
| void setRuntimeTrace( | void setRuntimeTrace( | ||||
| const LogicTraceSnapshot &trace, | const LogicTraceSnapshot &trace, | ||||
| const std::string &fault_node_id = {}); | const std::string &fault_node_id = {}); | ||||
| /** 清除当前运行轨迹和故障节点标记 */ | |||||
| void clearRuntimeTrace(); | void clearRuntimeTrace(); | ||||
| /** 返回当前是否正在显示运行轨迹 */ | |||||
| bool runtimeTraceEnabled() const; | bool runtimeTraceEnabled() const; | ||||
| /** 根据当前逻辑模型重建梯形图场景 */ | |||||
| void reloadLogic(); | void reloadLogic(); | ||||
| /** 选中指定节点并滚动到可见区域 */ | |||||
| void clearSelection(); | |||||
| void selectNode(const std::string &node_id); | void selectNode(const std::string &node_id); | ||||
| /** 返回当前选中的单个节点标识 */ | |||||
| /** 选中并滚动到语法错误位置,column 使用界面显示的 1 基列号 */ | |||||
| void focusSyntaxLocation(const std::string &rung_id, int column); | |||||
| std::string selectedNodeId() const; | std::string selectedNodeId() const; | ||||
| /** 返回当前选中的多个节点标识 */ | |||||
| std::vector<std::string> selectedNodeIds() const; | std::vector<std::string> selectedNodeIds() const; | ||||
| /** 返回当前选中的网络标识 */ | |||||
| std::string selectedRungId() const; | std::string selectedRungId() const; | ||||
| bool hasCopyableSelection() const; | |||||
| LogicClipboardCopyResult copySelection() const; | |||||
| LogicPasteTarget pasteTarget() const; | |||||
| /** 新增一个空的梯形图网络 */ | |||||
| LogicEditorResult addRung(); | LogicEditorResult addRung(); | ||||
| /** 将当前选择转换为服务调用并新增串联条件 */ | |||||
| LogicEditorResult insertRung(bool after); | |||||
| LogicEditorResult deleteRung(); | |||||
| LogicEditorResult addCondition(const LogicNodeConfig &config); | LogicEditorResult addCondition(const LogicNodeConfig &config); | ||||
| /** 将当前选择转换为服务调用并新增并联支路 */ | |||||
| LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | ||||
| /** 在当前选择位置新增横线 */ | |||||
| LogicEditorResult addHorizontalWire(); | LogicEditorResult addHorizontalWire(); | ||||
| /** 在当前选择位置新增竖线 */ | |||||
| LogicEditorResult addVerticalWire(); | LogicEditorResult addVerticalWire(); | ||||
| /** 删除当前选择位置的横线 */ | |||||
| LogicEditorResult deleteHorizontalWire(); | LogicEditorResult deleteHorizontalWire(); | ||||
| /** 删除当前选择位置的竖线 */ | |||||
| LogicEditorResult deleteVerticalWire(); | LogicEditorResult deleteVerticalWire(); | ||||
| /** 设置当前网络的输出线圈 */ | |||||
| LogicEditorResult setOutput( | LogicEditorResult setOutput( | ||||
| const LogicNodeConfig &config, bool configured = false); | const LogicNodeConfig &config, bool configured = false); | ||||
| /** 按当前画布选择位置粘贴一组连续条件 */ | |||||
| LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes); | |||||
| /** 删除当前选中的节点、线段或网络 */ | |||||
| LogicClipboardPasteResult pasteClipboard( | |||||
| const LogicClipboardFragment &fragment); | |||||
| LogicEditorResult deleteSelected(); | LogicEditorResult deleteSelected(); | ||||
| signals: | signals: | ||||
| /** 当前选中的节点发生变化时发出 */ | |||||
| void nodeSelected(const QString &node_id); | void nodeSelected(const QString &node_id); | ||||
| /** 梯形图模型成功发生变化时发出 */ | |||||
| void graphChanged(); | void graphChanged(); | ||||
| /** 编辑服务操作失败时发出可显示的错误信息 */ | |||||
| void editorError(const QString &message); | void editorError(const QString &message); | ||||
| protected: | protected: | ||||
| /** 在任意网络区域开始鼠标框选 */ | |||||
| void mousePressEvent(QMouseEvent *event) override; | void mousePressEvent(QMouseEvent *event) override; | ||||
| /** 更新鼠标框选区域 */ | |||||
| void mouseMoveEvent(QMouseEvent *event) override; | void mouseMoveEvent(QMouseEvent *event) override; | ||||
| /** 完成鼠标框选并提交场景选择 */ | |||||
| void mouseReleaseEvent(QMouseEvent *event) override; | void mouseReleaseEvent(QMouseEvent *event) override; | ||||
| /** 在条件网格或输出槽双击时打开命令输入框 */ | |||||
| void mouseDoubleClickEvent(QMouseEvent *event) override; | void mouseDoubleClickEvent(QMouseEvent *event) override; | ||||
| /** 在窗口大小变化后重新布局梯形图场景 */ | |||||
| void resizeEvent(QResizeEvent *event) override; | void resizeEvent(QResizeEvent *event) override; | ||||
| /** 滚动画布时同步命令输入框位置 */ | |||||
| void scrollContentsBy(int dx, int dy) override; | void scrollContentsBy(int dx, int dy) override; | ||||
| /** 处理命令输入框的回车、取消和按键事件 */ | |||||
| bool eventFilter(QObject *watched, QEvent *event) override; | bool eventFilter(QObject *watched, QEvent *event) override; | ||||
| private: | private: | ||||
| /** 处理图形场景选择变化 */ | |||||
| void handleSelectionChanged(); | |||||
| /** 把编辑服务结果转换为错误信号 */ | |||||
| struct RowLayout | |||||
| { | |||||
| std::string rungId; | |||||
| int row = -1; | |||||
| qreal gridTop = 0.0; | |||||
| qreal centerY = 0.0; | |||||
| qreal bottom = 0.0; | |||||
| bool networkHead = false; | |||||
| }; | |||||
| struct Hit | |||||
| { | |||||
| std::string rungId; | |||||
| int column = -1; | |||||
| LadderCellKind cellKind = LadderCellKind::Gap; | |||||
| bool output = false; | |||||
| bool vertical = false; | |||||
| bool boundary = false; | |||||
| bool rowHeader = false; | |||||
| std::string lowerRungId; | |||||
| std::string objectId; | |||||
| }; | |||||
| void reportFailure(const LogicEditorResult &result); | void reportFailure(const LogicEditorResult &result); | ||||
| /** 返回当前选中的网络标识 */ | |||||
| std::string currentRungId() const; | std::string currentRungId() const; | ||||
| /** 选中指定表达式图元 */ | |||||
| void selectExpression(const std::string &expression_id); | |||||
| /** 选中指定横线图元 */ | |||||
| void selectWire(const std::string &wire_id); | |||||
| /** 收集当前选中的表达式标识 */ | |||||
| std::vector<std::string> selectedExpressionIds() const; | |||||
| /** 收集当前选中的横线标识 */ | |||||
| std::vector<std::string> selectedWireIds() const; | |||||
| /** 收集当前选中的并联支路标识 */ | |||||
| std::vector<std::string> selectedBranchIds() const; | |||||
| /** 收集当前选中的空网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedEmptySlots() const; | |||||
| /** 收集当前选中的横线网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedWireCells() const; | |||||
| /** 收集当前选中的显式断路网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedGapCells() const; | |||||
| /** 判断当前是否选中了网络内的图元 */ | |||||
| bool hasSelectedRungItem() const; | |||||
| /** 收集当前明确选中的网络图元 */ | |||||
| std::vector<std::string> selectedRungItemIds() const; | |||||
| /** 打开指定目标位置的命令输入框 */ | |||||
| void beginCommandInput( | |||||
| const LogicCommandTarget &target, | |||||
| const QPointF &scene_center); | |||||
| /** 提交当前命令输入;失败时保留输入框并标红 */ | |||||
| Hit hitAt(const QPointF &scene_position) const; | |||||
| void beginGesture(const Hit &hit); | |||||
| void updateGesture(const Hit &hit); | |||||
| void finishGesture(const Hit &hit); | |||||
| void showCommandEditor(const Hit &hit); | |||||
| void commitCommandInput(); | void commitCommandInput(); | ||||
| /** 关闭命令输入框并清理临时状态 */ | |||||
| void cancelCommandInput(); | void cancelCommandInput(); | ||||
| /** 根据网络、列和支路标识查找下一输入位置 */ | |||||
| bool beginNextCommandInput(); | |||||
| /** 按目标定位输入框的屏幕矩形 */ | |||||
| void positionCommandInput(const QPointF &scene_center); | void positionCommandInput(const QPointF &scene_center); | ||||
| /** 返回当前场景目标的中心位置 */ | |||||
| bool findCommandTargetCenter( | bool findCommandTargetCenter( | ||||
| const LogicCommandTarget &target, | const LogicCommandTarget &target, | ||||
| QPointF *scene_center) const; | QPointF *scene_center) const; | ||||
| /** 将成功命令选中并刷新属性面板 */ | |||||
| void selectCommandResult(const LogicCommandResult &result); | |||||
| LogicCommandTarget commandTargetForCursor( | |||||
| const LogicEditCursor &cursor) const; | |||||
| LogicEditCursor conditionInsertionCursor() const; | |||||
| void moveToCursor(const LogicEditCursor &cursor); | |||||
| LogicEditorResult finishCursorEdit(const LogicEditResult &result); | |||||
| void rebuildScene(); | |||||
| void selectObject(const Hit &hit, bool extend_node_selection = false); | |||||
| void selectObjectsInBand(const QRect &viewport_rect, bool extend_selection); | |||||
| void clearObjectSelection(); | |||||
| void synchronizeSelectedNodes(); | |||||
| void notifySelectionChanged(); | |||||
| void rebuildRowLayout(const ControlLogic &logic); | |||||
| const RowLayout *layoutForRung(const std::string &rung_id) const; | |||||
| int rowAt(const std::string &rung_id) const; | |||||
| /** 梯形图编辑服务,不由控件拥有 */ | |||||
| LogicEditorService &editor_service_; | LogicEditorService &editor_service_; | ||||
| /** 命令语解析和结构化编辑适配服务 */ | |||||
| LogicCommandService command_service_; | LogicCommandService command_service_; | ||||
| /** 承载梯形图图元的场景 */ | |||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| /** 当前显示的控制逻辑标识 */ | |||||
| std::string logic_id_; | std::string logic_id_; | ||||
| /** 当前选中的网络标识 */ | |||||
| std::string current_rung_id_; | |||||
| /** 最近一次收到的运行轨迹 */ | |||||
| std::string selected_rung_id_; | |||||
| std::vector<std::string> selected_node_ids_; | |||||
| std::vector<std::pair<std::string, int>> selected_cells_; | |||||
| std::vector<std::string> selected_output_rung_ids_; | |||||
| std::vector<std::string> selected_vertical_connection_ids_; | |||||
| std::vector<std::string> selected_row_ids_; | |||||
| std::vector<RowLayout> row_layouts_; | |||||
| LogicTraceSnapshot trace_; | LogicTraceSnapshot trace_; | ||||
| /** 最近一次运行故障节点标识 */ | |||||
| std::string fault_node_id_; | std::string fault_node_id_; | ||||
| /** 是否显示运行轨迹 */ | |||||
| bool runtime_trace_enabled_ = false; | bool runtime_trace_enabled_ = false; | ||||
| /** 是否允许编辑梯形图 */ | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| /** 鼠标框选覆盖层 */ | |||||
| MouseWireMode mouse_wire_mode_ = MouseWireMode::Select; | |||||
| int selected_column_ = -1; | |||||
| bool selected_cell_ = false; | |||||
| bool selected_output_ = false; | |||||
| bool selected_boundary_ = false; | |||||
| std::string selected_vertical_connection_id_; | |||||
| QRubberBand *selection_band_ = nullptr; | QRubberBand *selection_band_ = nullptr; | ||||
| /** 框选起点(视口坐标) */ | |||||
| QPoint selection_origin_; | QPoint selection_origin_; | ||||
| /** 框选时是否已超过拖拽阈值 */ | |||||
| bool selection_pressed_ = false; | |||||
| bool selection_dragging_ = false; | bool selection_dragging_ = false; | ||||
| /** 框选起始时的键盘修饰键 */ | |||||
| Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; | Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; | ||||
| /** 命令输入框,仅在编辑态临时创建 */ | |||||
| bool gesture_active_ = false; | |||||
| Hit gesture_origin_; | |||||
| Hit gesture_current_; | |||||
| QLineEdit *command_editor_ = nullptr; | QLineEdit *command_editor_ = nullptr; | ||||
| /** 命令输入补全器 */ | |||||
| QCompleter *command_completer_ = nullptr; | QCompleter *command_completer_ = nullptr; | ||||
| /** 当前命令输入目标 */ | |||||
| LogicCommandTarget command_target_; | LogicCommandTarget command_target_; | ||||
| /** 是否沿当前命令序列继续输入 */ | |||||
| bool command_continuing_ = false; | |||||
| /** OR/ORI 只增加支路,不推进主表达式列 */ | |||||
| bool command_keep_column_ = false; | |||||
| /** 连续输入对应的当前网络 */ | |||||
| std::string command_current_rung_id_; | |||||
| /** 编辑已有网络 OR 时保留的选择范围 */ | |||||
| std::vector<std::string> command_parallel_node_ids_; | std::vector<std::string> command_parallel_node_ids_; | ||||
| }; | }; | ||||
| @@ -40,6 +40,8 @@ | |||||
| #include <QIcon> | #include <QIcon> | ||||
| #include <QLabel> | #include <QLabel> | ||||
| #include <QLineEdit> | #include <QLineEdit> | ||||
| #include <QListWidget> | |||||
| #include <QListWidgetItem> | |||||
| #include <QKeyEvent> | #include <QKeyEvent> | ||||
| #include <QKeySequence> | #include <QKeySequence> | ||||
| #include <QMessageBox> | #include <QMessageBox> | ||||
| @@ -55,11 +57,16 @@ | |||||
| #include <QDir> | #include <QDir> | ||||
| #include <QFileInfo> | #include <QFileInfo> | ||||
| #include <QProgressDialog> | #include <QProgressDialog> | ||||
| #include <QSignalBlocker> | |||||
| #include <algorithm> | #include <algorithm> | ||||
| namespace { | namespace { | ||||
| constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1; | |||||
| constexpr int kSyntaxRungIdRole = Qt::UserRole + 2; | |||||
| constexpr int kSyntaxColumnRole = Qt::UserRole + 3; | |||||
| bool isTextEditingObject(QObject *object) | bool isTextEditingObject(QObject *object) | ||||
| { | { | ||||
| QWidget *widget = qobject_cast<QWidget *>(object); | QWidget *widget = qobject_cast<QWidget *>(object); | ||||
| @@ -612,6 +619,33 @@ void MainWindow::configureActions() | |||||
| this, &MainWindow::deleteActiveSelection); | this, &MainWindow::deleteActiveSelection); | ||||
| connect(ui_->clearSelectionAction, &QAction::triggered, | connect(ui_->clearSelectionAction, &QAction::triggered, | ||||
| this, &MainWindow::clearActiveSelection); | this, &MainWindow::clearActiveSelection); | ||||
| connect(ui_->syntaxCheckAction, &QAction::triggered, | |||||
| this, &MainWindow::runLogicSyntaxCheck); | |||||
| connect(ui_->doubleCoilCheckAction, &QAction::triggered, | |||||
| this, &MainWindow::runDoubleCoilCheck); | |||||
| connect(ui_->outputList, &QListWidget::itemDoubleClicked, | |||||
| this, | |||||
| [this](QListWidgetItem *item) | |||||
| { | |||||
| if (item == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const std::string logic_id = toUtf8( | |||||
| item->data(kSyntaxLogicIdRole).toString()); | |||||
| const std::string rung_id = toUtf8( | |||||
| item->data(kSyntaxRungIdRole).toString()); | |||||
| if (logic_id.empty() || rung_id.empty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| focusLogicSyntaxLocation({ | |||||
| logic_id, | |||||
| rung_id, | |||||
| 0, | |||||
| 0, | |||||
| item->data(kSyntaxColumnRole).toInt()}); | |||||
| }); | |||||
| connect(ui_->addButtonAction, &QAction::triggered, this, | connect(ui_->addButtonAction, &QAction::triggered, this, | ||||
| [this] { addHmiControl(HmiControlType::Button); }); | [this] { addHmiControl(HmiControlType::Button); }); | ||||
| @@ -641,6 +675,44 @@ void MainWindow::configureActions() | |||||
| connect(ui_->addRungAction, &QAction::triggered, | connect(ui_->addRungAction, &QAction::triggered, | ||||
| this, &MainWindow::addLogicRung); | this, &MainWindow::addLogicRung); | ||||
| connect(ui_->insertRungAboveAction, &QAction::triggered, | |||||
| this, &MainWindow::insertLogicRungAbove); | |||||
| connect(ui_->insertRungBelowAction, &QAction::triggered, | |||||
| this, &MainWindow::insertLogicRungBelow); | |||||
| connect(ui_->deleteRungAction, &QAction::triggered, | |||||
| this, &MainWindow::deleteLogicRung); | |||||
| connect(ui_->mouseDrawWireAction, &QAction::triggered, | |||||
| this, | |||||
| [this](bool checked) | |||||
| { | |||||
| if (checked) | |||||
| { | |||||
| const QSignalBlocker blocker(ui_->mouseEraseWireAction); | |||||
| ui_->mouseEraseWireAction->setChecked(false); | |||||
| } | |||||
| logic_editor_widget_->setMouseWireMode( | |||||
| checked | |||||
| ? LogicEditorWidget::MouseWireMode::Draw | |||||
| : ui_->mouseEraseWireAction->isChecked() | |||||
| ? LogicEditorWidget::MouseWireMode::Erase | |||||
| : LogicEditorWidget::MouseWireMode::Select); | |||||
| }); | |||||
| connect(ui_->mouseEraseWireAction, &QAction::triggered, | |||||
| this, | |||||
| [this](bool checked) | |||||
| { | |||||
| if (checked) | |||||
| { | |||||
| const QSignalBlocker blocker(ui_->mouseDrawWireAction); | |||||
| ui_->mouseDrawWireAction->setChecked(false); | |||||
| } | |||||
| logic_editor_widget_->setMouseWireMode( | |||||
| checked | |||||
| ? LogicEditorWidget::MouseWireMode::Erase | |||||
| : ui_->mouseDrawWireAction->isChecked() | |||||
| ? LogicEditorWidget::MouseWireMode::Draw | |||||
| : LogicEditorWidget::MouseWireMode::Select); | |||||
| }); | |||||
| connect(ui_->insertHorizontalWireAction, &QAction::triggered, | connect(ui_->insertHorizontalWireAction, &QAction::triggered, | ||||
| this, &MainWindow::addLogicHorizontalWire); | this, &MainWindow::addLogicHorizontalWire); | ||||
| connect(ui_->insertVerticalWireAction, &QAction::triggered, | connect(ui_->insertVerticalWireAction, &QAction::triggered, | ||||
| @@ -911,6 +983,11 @@ void MainWindow::configureAppearance() | |||||
| ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); | ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); | ||||
| ui_->deleteControlAction->setIcon(makeUiIcon(UiIcon::Delete)); | ui_->deleteControlAction->setIcon(makeUiIcon(UiIcon::Delete)); | ||||
| ui_->addRungAction->setIcon(makeUiIcon(UiIcon::AddRung)); | ui_->addRungAction->setIcon(makeUiIcon(UiIcon::AddRung)); | ||||
| ui_->insertRungAboveAction->setIcon(makeUiIcon(UiIcon::AddRung)); | |||||
| ui_->insertRungBelowAction->setIcon(makeUiIcon(UiIcon::AddRung)); | |||||
| ui_->deleteRungAction->setIcon(makeUiIcon(UiIcon::Delete)); | |||||
| ui_->mouseDrawWireAction->setIcon(makeUiIcon(UiIcon::MouseDrawWire)); | |||||
| ui_->mouseEraseWireAction->setIcon(makeUiIcon(UiIcon::MouseEraseWire)); | |||||
| ui_->parallelInsertAction->setIcon(makeUiIcon(UiIcon::ParallelBranch)); | ui_->parallelInsertAction->setIcon(makeUiIcon(UiIcon::ParallelBranch)); | ||||
| ui_->insertHorizontalWireAction->setIcon(makeUiIcon(UiIcon::HorizontalWire)); | ui_->insertHorizontalWireAction->setIcon(makeUiIcon(UiIcon::HorizontalWire)); | ||||
| ui_->insertVerticalWireAction->setIcon(makeUiIcon(UiIcon::VerticalWire)); | ui_->insertVerticalWireAction->setIcon(makeUiIcon(UiIcon::VerticalWire)); | ||||
| @@ -928,6 +1005,8 @@ void MainWindow::configureAppearance() | |||||
| ui_->addSubAction->setIcon(makeUiIcon(UiIcon::Subtract)); | ui_->addSubAction->setIcon(makeUiIcon(UiIcon::Subtract)); | ||||
| ui_->addCompareAction->setIcon(makeUiIcon(UiIcon::Compare)); | ui_->addCompareAction->setIcon(makeUiIcon(UiIcon::Compare)); | ||||
| ui_->editRungCommentAction->setIcon(makeUiIcon(UiIcon::Comment)); | ui_->editRungCommentAction->setIcon(makeUiIcon(UiIcon::Comment)); | ||||
| ui_->syntaxCheckAction->setIcon(makeUiIcon(UiIcon::SyntaxCheck)); | |||||
| ui_->doubleCoilCheckAction->setIcon(makeUiIcon(UiIcon::Coil)); | |||||
| ui_->deleteLogicAction->setIcon(makeUiIcon(UiIcon::Delete)); | ui_->deleteLogicAction->setIcon(makeUiIcon(UiIcon::Delete)); | ||||
| ui_->editorTabWidget->setTabIcon(0, makeUiIcon(UiIcon::HmiPage)); | ui_->editorTabWidget->setTabIcon(0, makeUiIcon(UiIcon::HmiPage)); | ||||
| ui_->editorTabWidget->setTabIcon(1, makeUiIcon(UiIcon::Logic)); | ui_->editorTabWidget->setTabIcon(1, makeUiIcon(UiIcon::Logic)); | ||||
| @@ -992,6 +1071,12 @@ void MainWindow::configureLogicEditor() | |||||
| layout->addWidget(logic_editor_widget_); | layout->addWidget(logic_editor_widget_); | ||||
| property_panel_controller_->bindEditorWidgets( | property_panel_controller_->bindEditorWidgets( | ||||
| *hmi_editor_widget_, *logic_editor_widget_); | *hmi_editor_widget_, *logic_editor_widget_); | ||||
| connect( | |||||
| hmi_editor_widget_, &HmiEditorWidget::controlSelected, | |||||
| this, [this](const QString &) { updateEditActions(); }); | |||||
| connect( | |||||
| logic_editor_widget_, &LogicEditorWidget::nodeSelected, | |||||
| this, [this](const QString &) { updateEditActions(); }); | |||||
| } | } | ||||
| void MainWindow::configureRuntimeMonitor() | void MainWindow::configureRuntimeMonitor() | ||||
| @@ -1275,57 +1360,29 @@ void MainWindow::copyActiveSelection() | |||||
| } | } | ||||
| else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | ||||
| { | { | ||||
| const std::vector<std::string> ids = logic_editor_widget_->selectedNodeIds(); | |||||
| if (!ids.empty()) | |||||
| LogicClipboardCopyResult result = logic_editor_widget_->copySelection(); | |||||
| if (!result.copy.succeeded) | |||||
| { | { | ||||
| const std::string rung_id = logic_editor_widget_->selectedRungId(); | |||||
| if (rung_id.empty()) | |||||
| { | |||||
| statusBar()->showMessage(tr("只能复制同一网络中的梯形图指令"), 3000); | |||||
| return; | |||||
| } | |||||
| std::vector<LogicNode> nodes; | |||||
| nodes.reserve(ids.size()); | |||||
| bool all_conditions = true; | |||||
| for (const std::string &id : ids) | |||||
| { | |||||
| const LogicNode *node = logic_editor_service_.findNode( | |||||
| current_logic_id_, id); | |||||
| if (node == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| all_conditions = all_conditions && node->isCondition(); | |||||
| nodes.push_back(*node); | |||||
| } | |||||
| if (all_conditions | |||||
| && logic_editor_service_.areConditionNodesContiguous( | |||||
| current_logic_id_, rung_id, ids)) | |||||
| { | |||||
| editor_clipboard_ = LogicNodesClipboardData{std::move(nodes)}; | |||||
| statusBar()->showMessage(tr("已复制 %1 个梯形图条件").arg(ids.size()), 3000); | |||||
| } | |||||
| else if (ids.size() == 1U && nodes.front().isOutput()) | |||||
| { | |||||
| editor_clipboard_ = LogicOutputClipboardData{nodes.front()}; | |||||
| statusBar()->showMessage(tr("已复制梯形图输出指令"), 3000); | |||||
| } | |||||
| else | |||||
| { | |||||
| statusBar()->showMessage( | |||||
| tr("只能复制同一串联层级中连续的条件,或单个输出指令"), 3000); | |||||
| } | |||||
| updateEditActions(); | |||||
| statusBar()->showMessage(fromUtf8(result.copy.message), 5000); | |||||
| return; | return; | ||||
| } | } | ||||
| const std::string rung_id = logic_editor_widget_->selectedRungId(); | |||||
| const LadderRung *rung = logic_editor_service_.findRung( | |||||
| current_logic_id_, rung_id); | |||||
| if (rung != nullptr) | |||||
| const bool whole_rows = | |||||
| result.fragment.mode == LogicClipboardMode::WholeRows; | |||||
| const std::size_t object_count = result.fragment.cells.size() | |||||
| + result.fragment.outputs.size() | |||||
| + result.fragment.verticalConnections.size(); | |||||
| editor_clipboard_ = LogicClipboardData{std::move(result.fragment)}; | |||||
| if (whole_rows) | |||||
| { | { | ||||
| editor_clipboard_ = LogicRungClipboardData{*rung}; | |||||
| statusBar()->showMessage(tr("已复制整条梯形图网络"), 3000); | |||||
| const auto *data = std::get_if<LogicClipboardData>(&editor_clipboard_); | |||||
| statusBar()->showMessage( | |||||
| tr("已复制 %1 行梯形图").arg(data->fragment.rows.size()), | |||||
| 3000); | |||||
| } | |||||
| else | |||||
| { | |||||
| statusBar()->showMessage( | |||||
| tr("已复制 %1 个梯形图对象").arg(object_count), 3000); | |||||
| } | } | ||||
| } | } | ||||
| updateEditActions(); | updateEditActions(); | ||||
| @@ -1362,37 +1419,24 @@ void MainWindow::pasteActiveSelection() | |||||
| } | } | ||||
| else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | ||||
| { | { | ||||
| LogicEditorResult result; | |||||
| if (const auto *data = std::get_if<LogicNodesClipboardData>(&editor_clipboard_)) | |||||
| { | |||||
| result = logic_editor_widget_->pasteConditionNodes(data->nodes); | |||||
| if (result.succeeded) | |||||
| { | |||||
| selected_logic_node_id_ = result.id; | |||||
| showLogicNodeProperties(result.id); | |||||
| } | |||||
| } | |||||
| else if (const auto *data = std::get_if<LogicOutputClipboardData>(&editor_clipboard_)) | |||||
| { | |||||
| result = logic_editor_widget_->setOutput( | |||||
| data->output.config, data->output.configured); | |||||
| } | |||||
| else if (const auto *data = std::get_if<LogicRungClipboardData>(&editor_clipboard_)) | |||||
| const auto *data = std::get_if<LogicClipboardData>(&editor_clipboard_); | |||||
| if (data == nullptr) | |||||
| { | { | ||||
| result = logic_editor_service_.pasteRung(current_logic_id_, data->rung); | |||||
| if (result.succeeded) | |||||
| { | |||||
| logic_editor_widget_->reloadLogic(); | |||||
| } | |||||
| return; | |||||
| } | } | ||||
| if (!result.succeeded) | |||||
| const LogicClipboardPasteResult result = | |||||
| logic_editor_widget_->pasteClipboard(data->fragment); | |||||
| if (!result.edit.succeeded) | |||||
| { | { | ||||
| if (!result.message.empty()) | |||||
| if (!result.edit.message.empty()) | |||||
| { | { | ||||
| showProjectResult(tr("粘贴梯形图"), fromUtf8(result.message), false); | |||||
| showProjectResult( | |||||
| tr("粘贴梯形图"), fromUtf8(result.edit.message), false); | |||||
| } | } | ||||
| return; | return; | ||||
| } | } | ||||
| selected_logic_node_id_ = logic_editor_widget_->selectedNodeId(); | |||||
| showLogicNodeProperties(selected_logic_node_id_); | |||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000); | statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000); | ||||
| } | } | ||||
| @@ -1421,7 +1465,7 @@ void MainWindow::clearActiveSelection() | |||||
| } | } | ||||
| else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | ||||
| { | { | ||||
| logic_editor_widget_->scene()->clearSelection(); | |||||
| logic_editor_widget_->clearSelection(); | |||||
| selected_logic_node_id_.clear(); | selected_logic_node_id_.clear(); | ||||
| showLogicNodeProperties({}); | showLogicNodeProperties({}); | ||||
| } | } | ||||
| @@ -1439,19 +1483,32 @@ void MainWindow::updateEditActions() | |||||
| ui_->redoAction->setEnabled( | ui_->redoAction->setEnabled( | ||||
| editable && ((hmi_active && hmi_editor_service_.canRedo()) | editable && ((hmi_active && hmi_editor_service_.canRedo()) | ||||
| || (logic_active && logic_editor_service_.canRedo()))); | || (logic_active && logic_editor_service_.canRedo()))); | ||||
| ui_->copyAction->setEnabled(editable && (hmi_active || logic_active)); | |||||
| const bool hmi_selection = hmi_active | |||||
| && !hmi_editor_widget_->selectedControlIds().empty(); | |||||
| const bool logic_selection = logic_active | |||||
| && logic_editor_widget_->hasCopyableSelection(); | |||||
| ui_->copyAction->setEnabled( | |||||
| editable && (hmi_selection || logic_selection)); | |||||
| const bool hmi_clipboard = std::holds_alternative<HmiClipboardData>(editor_clipboard_); | const bool hmi_clipboard = std::holds_alternative<HmiClipboardData>(editor_clipboard_); | ||||
| const bool logic_clipboard = std::holds_alternative<LogicNodesClipboardData>(editor_clipboard_) | |||||
| || std::holds_alternative<LogicOutputClipboardData>(editor_clipboard_) | |||||
| || std::holds_alternative<LogicRungClipboardData>(editor_clipboard_); | |||||
| const bool logic_clipboard = | |||||
| std::holds_alternative<LogicClipboardData>(editor_clipboard_); | |||||
| ui_->pasteAction->setEnabled(editable && ((hmi_active && hmi_clipboard) | ui_->pasteAction->setEnabled(editable && ((hmi_active && hmi_clipboard) | ||||
| || (logic_active && logic_clipboard))); | || (logic_active && logic_clipboard))); | ||||
| ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | ||||
| ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | ||||
| ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); | ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); | ||||
| ui_->insertVerticalWireAction->setEnabled(editable && logic_active); | ui_->insertVerticalWireAction->setEnabled(editable && logic_active); | ||||
| ui_->insertRungAboveAction->setEnabled(editable && logic_active); | |||||
| ui_->insertRungBelowAction->setEnabled(editable && logic_active); | |||||
| ui_->deleteRungAction->setEnabled(editable && logic_active); | |||||
| ui_->deleteHorizontalWireAction->setEnabled(editable && logic_active); | ui_->deleteHorizontalWireAction->setEnabled(editable && logic_active); | ||||
| ui_->deleteVerticalWireAction->setEnabled(editable && logic_active); | ui_->deleteVerticalWireAction->setEnabled(editable && logic_active); | ||||
| ui_->mouseDrawWireAction->setEnabled(editable && logic_active); | |||||
| ui_->mouseEraseWireAction->setEnabled(editable && logic_active); | |||||
| ui_->syntaxCheckAction->setEnabled( | |||||
| editable && !current_logic_id_.empty()); | |||||
| ui_->doubleCoilCheckAction->setEnabled( | |||||
| editable && !current_logic_id_.empty()); | |||||
| } | } | ||||
| void MainWindow::clearEditorHistories() | void MainWindow::clearEditorHistories() | ||||
| @@ -1645,7 +1702,7 @@ void MainWindow::addLogicVerticalWire() | |||||
| selected_logic_node_id_.clear(); | selected_logic_node_id_.clear(); | ||||
| showLogicNodeProperties({}); | showLogicNodeProperties({}); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| statusBar()->showMessage(tr("已建立横线旁路和竖线连接"), 3000); | |||||
| statusBar()->showMessage(tr("竖线连接已建立"), 3000); | |||||
| } | } | ||||
| void MainWindow::deleteLogicHorizontalWire() | void MainWindow::deleteLogicHorizontalWire() | ||||
| @@ -1673,7 +1730,7 @@ void MainWindow::deleteLogicVerticalWire() | |||||
| selected_logic_node_id_.clear(); | selected_logic_node_id_.clear(); | ||||
| showLogicNodeProperties({}); | showLogicNodeProperties({}); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| statusBar()->showMessage(tr("竖线及对应并联支路已删除"), 3000); | |||||
| statusBar()->showMessage(tr("竖线已删除,网络已拆分"), 3000); | |||||
| } | } | ||||
| void MainWindow::setLogicOutput(const LogicNodeConfig &config) | void MainWindow::setLogicOutput(const LogicNodeConfig &config) | ||||
| @@ -1725,6 +1782,48 @@ void MainWindow::addLogicRung() | |||||
| statusBar()->showMessage(tr("已新建网络"), 3000); | statusBar()->showMessage(tr("已新建网络"), 3000); | ||||
| } | } | ||||
| void MainWindow::insertLogicRungAbove() | |||||
| { | |||||
| const LogicEditorResult result = logic_editor_widget_->insertRung(false); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| showProjectResult(tr("上方插入行"), fromUtf8(result.message), false); | |||||
| return; | |||||
| } | |||||
| selected_logic_node_id_.clear(); | |||||
| showLogicNodeProperties({}); | |||||
| refreshProjectUi(); | |||||
| statusBar()->showMessage(tr("已在当前行上方插入空白行"), 3000); | |||||
| } | |||||
| void MainWindow::insertLogicRungBelow() | |||||
| { | |||||
| const LogicEditorResult result = logic_editor_widget_->insertRung(true); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| showProjectResult(tr("下方插入行"), fromUtf8(result.message), false); | |||||
| return; | |||||
| } | |||||
| selected_logic_node_id_.clear(); | |||||
| showLogicNodeProperties({}); | |||||
| refreshProjectUi(); | |||||
| statusBar()->showMessage(tr("已在当前行下方插入空白行"), 3000); | |||||
| } | |||||
| void MainWindow::deleteLogicRung() | |||||
| { | |||||
| const LogicEditorResult result = logic_editor_widget_->deleteRung(); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| showProjectResult(tr("删除行"), fromUtf8(result.message), false); | |||||
| return; | |||||
| } | |||||
| selected_logic_node_id_.clear(); | |||||
| showLogicNodeProperties({}); | |||||
| refreshProjectUi(); | |||||
| statusBar()->showMessage(tr("当前行已删除,竖线连接已重新整理"), 3000); | |||||
| } | |||||
| void MainWindow::editSelectedRungComment() | void MainWindow::editSelectedRungComment() | ||||
| { | { | ||||
| const std::string rung_id = logic_editor_widget_->selectedRungId(); | const std::string rung_id = logic_editor_widget_->selectedRungId(); | ||||
| @@ -1763,6 +1862,62 @@ void MainWindow::editSelectedRungComment() | |||||
| statusBar()->showMessage(tr("网络注释已更新"), 3000); | statusBar()->showMessage(tr("网络注释已更新"), 3000); | ||||
| } | } | ||||
| void MainWindow::runLogicSyntaxCheck() | |||||
| { | |||||
| const LogicSyntaxCheckResult result = logic_editor_service_.checkSyntax( | |||||
| current_logic_id_); | |||||
| reportLogicSyntaxCheck(result, tr("语法检查")); | |||||
| } | |||||
| void MainWindow::runDoubleCoilCheck() | |||||
| { | |||||
| const LogicSyntaxCheckResult result = | |||||
| logic_editor_service_.checkDoubleCoils(current_logic_id_); | |||||
| reportLogicSyntaxCheck(result, tr("双线圈检查")); | |||||
| } | |||||
| void MainWindow::reportLogicSyntaxCheck( | |||||
| const LogicSyntaxCheckResult &result, const QString &action) | |||||
| { | |||||
| if (result.changed) | |||||
| { | |||||
| logic_editor_widget_->reloadLogic(); | |||||
| refreshProjectUi(); | |||||
| } | |||||
| QString message = action + QStringLiteral(": ") + fromUtf8(result.message); | |||||
| if (result.removedWireCells > 0U | |||||
| || result.removedVerticalConnections > 0U) | |||||
| { | |||||
| message += tr(";已规整 %1 格横线、%2 段竖线") | |||||
| .arg(result.removedWireCells) | |||||
| .arg(result.removedVerticalConnections); | |||||
| } | |||||
| appendOutputMessage(message, result.location); | |||||
| ui_->outputDock->show(); | |||||
| ui_->outputDock->raise(); | |||||
| statusBar()->showMessage(message, 6000); | |||||
| if (!result.valid && result.location.has_value()) | |||||
| { | |||||
| focusLogicSyntaxLocation(*result.location); | |||||
| } | |||||
| updateEditActions(); | |||||
| } | |||||
| void MainWindow::focusLogicSyntaxLocation( | |||||
| const LogicSyntaxLocation &location) | |||||
| { | |||||
| if (logic_editor_service_.findRung( | |||||
| location.logicId, location.rungId) == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| current_logic_id_ = location.logicId; | |||||
| ui_->editorTabWidget->setCurrentWidget(ui_->logicEditorTab); | |||||
| refreshProjectUi(); | |||||
| logic_editor_widget_->focusSyntaxLocation( | |||||
| location.rungId, location.column); | |||||
| } | |||||
| void MainWindow::deleteSelectedLogicObject() | void MainWindow::deleteSelectedLogicObject() | ||||
| { | { | ||||
| const LogicEditorResult result = logic_editor_widget_->deleteSelected(); | const LogicEditorResult result = logic_editor_widget_->deleteSelected(); | ||||
| @@ -1902,6 +2057,16 @@ void MainWindow::loadProject() | |||||
| void MainWindow::exportRuntimeProgram() | void MainWindow::exportRuntimeProgram() | ||||
| { | { | ||||
| const LogicSyntaxCheckResult syntax = | |||||
| logic_editor_service_.checkEnabledSyntax(); | |||||
| if (syntax.changed || !syntax.completed || !syntax.valid) | |||||
| { | |||||
| reportLogicSyntaxCheck(syntax, tr("导出前语法检查")); | |||||
| } | |||||
| if (!syntax.completed || !syntax.valid) | |||||
| { | |||||
| return; | |||||
| } | |||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project_service_.project().validateForRunning( | if (!project_service_.project().validateForRunning( | ||||
| project_service_.projectLimits(), &validation_error)) | project_service_.projectLimits(), &validation_error)) | ||||
| @@ -2106,7 +2271,9 @@ void MainWindow::showProjectResult( | |||||
| } | } | ||||
| } | } | ||||
| void MainWindow::appendOutputMessage(const QString &message) | |||||
| void MainWindow::appendOutputMessage( | |||||
| const QString &message, | |||||
| const std::optional<LogicSyntaxLocation> &location) | |||||
| { | { | ||||
| const int maximum = application_settings_result_ | const int maximum = application_settings_result_ | ||||
| .settings.projectLimits.maximumOutputMessages; | .settings.projectLimits.maximumOutputMessages; | ||||
| @@ -2114,7 +2281,19 @@ void MainWindow::appendOutputMessage(const QString &message) | |||||
| { | { | ||||
| delete ui_->outputList->takeItem(0); | delete ui_->outputList->takeItem(0); | ||||
| } | } | ||||
| ui_->outputList->addItem(message); | |||||
| auto *item = new QListWidgetItem(message, ui_->outputList); | |||||
| if (location.has_value()) | |||||
| { | |||||
| item->setData( | |||||
| kSyntaxLogicIdRole, fromUtf8(location->logicId)); | |||||
| item->setData( | |||||
| kSyntaxRungIdRole, fromUtf8(location->rungId)); | |||||
| item->setData(kSyntaxColumnRole, location->column); | |||||
| item->setToolTip(tr("双击定位到网络 %1,第 %2 行第 %3 列") | |||||
| .arg(location->network) | |||||
| .arg(location->row) | |||||
| .arg(location->column)); | |||||
| } | |||||
| ui_->outputList->scrollToBottom(); | ui_->outputList->scrollToBottom(); | ||||
| } | } | ||||
| @@ -2147,6 +2326,19 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| const bool runtime_request = requested_mode == ApplicationMode::OfflineRunning | |||||
| || requested_mode == ApplicationMode::OnlineRunning; | |||||
| if (runtime_request | |||||
| && (result.succeeded | |||||
| || result.error == ModeTransitionError::ProjectNotReady)) | |||||
| { | |||||
| const LogicSyntaxCheckResult &syntax = | |||||
| runtime_mode_service_.lastSyntaxCheck(); | |||||
| if (syntax.completed && (syntax.changed || !syntax.valid)) | |||||
| { | |||||
| reportLogicSyntaxCheck(syntax, tr("运行前语法检查")); | |||||
| } | |||||
| } | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| restoreCurrentModeAction(); | restoreCurrentModeAction(); | ||||
| @@ -2155,6 +2347,10 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) | |||||
| { | { | ||||
| message += tr(";当前状态:%1").arg(plcStatusText(runtime_mode_service_)); | message += tr(";当前状态:%1").arg(plcStatusText(runtime_mode_service_)); | ||||
| } | } | ||||
| if (!result.detail.empty()) | |||||
| { | |||||
| message += QStringLiteral(": ") + fromUtf8(result.detail); | |||||
| } | |||||
| if (result.error == ModeTransitionError::SimulationStartFailed) | if (result.error == ModeTransitionError::SimulationStartFailed) | ||||
| { | { | ||||
| const LogicScanResult &error = requested_mode | const LogicScanResult &error = requested_mode | ||||
| @@ -2211,6 +2407,13 @@ void MainWindow::updateModeUi(const QString &message) | |||||
| hmi_editor_widget_->setRuntimeActive( | hmi_editor_widget_->setRuntimeActive( | ||||
| policy.usesVirtualRegisters || policy.usesPlcRegisters); | policy.usesVirtualRegisters || policy.usesPlcRegisters); | ||||
| logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | ||||
| if (!policy.allowsProjectEditing) | |||||
| { | |||||
| const QSignalBlocker draw_blocker(ui_->mouseDrawWireAction); | |||||
| const QSignalBlocker erase_blocker(ui_->mouseEraseWireAction); | |||||
| ui_->mouseDrawWireAction->setChecked(false); | |||||
| ui_->mouseEraseWireAction->setChecked(false); | |||||
| } | |||||
| if (mode == ApplicationMode::Editing) | if (mode == ApplicationMode::Editing) | ||||
| { | { | ||||
| alarm_service_.reset(); | alarm_service_.reset(); | ||||
| @@ -13,10 +13,12 @@ | |||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| #include "services/application_settings.h" | #include "services/application_settings.h" | ||||
| #include "services/logic_editor_service.h" | |||||
| #include <QMainWindow> | #include <QMainWindow> | ||||
| #include <memory> | #include <memory> | ||||
| #include <optional> | |||||
| #include <variant> | #include <variant> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -193,8 +195,24 @@ private: | |||||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | void configureAndSetLogicOutput(const LogicNodeConfig &config); | ||||
| /** 新增一个梯形图网络 */ | /** 新增一个梯形图网络 */ | ||||
| void addLogicRung(); | void addLogicRung(); | ||||
| /** 在当前网络上方插入一行 */ | |||||
| void insertLogicRungAbove(); | |||||
| /** 在当前网络下方插入一行 */ | |||||
| void insertLogicRungBelow(); | |||||
| /** 删除当前选中的梯形图行 */ | |||||
| void deleteLogicRung(); | |||||
| /** 编辑当前网络的注释 */ | /** 编辑当前网络的注释 */ | ||||
| void editSelectedRungComment(); | void editSelectedRungComment(); | ||||
| /** 规整并检查当前梯形图 */ | |||||
| void runLogicSyntaxCheck(); | |||||
| /** 单独检查当前梯形图中的重复线圈输出 */ | |||||
| void runDoubleCoilCheck(); | |||||
| /** 将语法检查结果写入输出栏并定位错误 */ | |||||
| void reportLogicSyntaxCheck( | |||||
| const LogicSyntaxCheckResult &result, | |||||
| const QString &action); | |||||
| /** 切换到并聚焦语法错误所在的逻辑位置 */ | |||||
| void focusLogicSyntaxLocation(const LogicSyntaxLocation &location); | |||||
| /** 删除当前选中的逻辑节点或网络 */ | /** 删除当前选中的逻辑节点或网络 */ | ||||
| void deleteSelectedLogicObject(); | void deleteSelectedLogicObject(); | ||||
| /** 打开 PLC 连接 */ | /** 打开 PLC 连接 */ | ||||
| @@ -216,7 +234,9 @@ private: | |||||
| /** 在状态栏和输出面板显示工程操作结果 */ | /** 在状态栏和输出面板显示工程操作结果 */ | ||||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | void showProjectResult(const QString &action, const QString &message, bool succeeded); | ||||
| /** 向输出面板追加一条消息 */ | /** 向输出面板追加一条消息 */ | ||||
| void appendOutputMessage(const QString &message); | |||||
| void appendOutputMessage( | |||||
| const QString &message, | |||||
| const std::optional<LogicSyntaxLocation> &location = std::nullopt); | |||||
| /** | /** | ||||
| * @brief 请求服务层切换模式并同步界面状态 | * @brief 请求服务层切换模式并同步界面状态 | ||||
| @@ -321,23 +341,13 @@ private: | |||||
| std::vector<HmiControl> controls; | std::vector<HmiControl> controls; | ||||
| int pasteCount = 0; | int pasteCount = 0; | ||||
| }; | }; | ||||
| struct LogicNodesClipboardData | |||||
| struct LogicClipboardData | |||||
| { | { | ||||
| std::vector<LogicNode> nodes; | |||||
| }; | |||||
| struct LogicOutputClipboardData | |||||
| { | |||||
| LogicNode output; | |||||
| }; | |||||
| struct LogicRungClipboardData | |||||
| { | |||||
| LadderRung rung; | |||||
| LogicClipboardFragment fragment; | |||||
| }; | }; | ||||
| using EditorClipboard = std::variant< | using EditorClipboard = std::variant< | ||||
| std::monostate, | std::monostate, | ||||
| HmiClipboardData, | HmiClipboardData, | ||||
| LogicNodesClipboardData, | |||||
| LogicOutputClipboardData, | |||||
| LogicRungClipboardData>; | |||||
| LogicClipboardData>; | |||||
| EditorClipboard editor_clipboard_; | EditorClipboard editor_clipboard_; | ||||
| }; | }; | ||||
| @@ -261,14 +261,20 @@ | |||||
| <addaction name="pasteAction"/> | <addaction name="pasteAction"/> | ||||
| <addaction name="separator"/> | <addaction name="separator"/> | ||||
| <addaction name="deleteSelectionAction"/> | <addaction name="deleteSelectionAction"/> | ||||
| <addaction name="insertRungAboveAction"/> | |||||
| <addaction name="insertRungBelowAction"/> | |||||
| <addaction name="deleteRungAction"/> | |||||
| <addaction name="deleteHorizontalWireAction"/> | <addaction name="deleteHorizontalWireAction"/> | ||||
| <addaction name="deleteVerticalWireAction"/> | <addaction name="deleteVerticalWireAction"/> | ||||
| <addaction name="clearSelectionAction"/> | <addaction name="clearSelectionAction"/> | ||||
| </widget> | </widget> | ||||
| <widget class="QMenu" name="runMenu"> | <widget class="QMenu" name="runMenu"> | ||||
| <property name="title"> | |||||
| <string>运行(&R)</string> | |||||
| </property> | |||||
| <property name="title"> | |||||
| <string>运行(&R)</string> | |||||
| </property> | |||||
| <addaction name="syntaxCheckAction"/> | |||||
| <addaction name="doubleCoilCheckAction"/> | |||||
| <addaction name="separator"/> | |||||
| <addaction name="editingModeAction"/> | <addaction name="editingModeAction"/> | ||||
| <addaction name="offlineModeAction"/> | <addaction name="offlineModeAction"/> | ||||
| <addaction name="onlineModeAction"/> | <addaction name="onlineModeAction"/> | ||||
| @@ -344,6 +350,12 @@ | |||||
| <bool>true</bool> | <bool>true</bool> | ||||
| </attribute> | </attribute> | ||||
| <addaction name="addRungAction"/> | <addaction name="addRungAction"/> | ||||
| <addaction name="insertRungAboveAction"/> | |||||
| <addaction name="insertRungBelowAction"/> | |||||
| <addaction name="deleteRungAction"/> | |||||
| <addaction name="mouseDrawWireAction"/> | |||||
| <addaction name="mouseEraseWireAction"/> | |||||
| <addaction name="separator"/> | |||||
| <addaction name="insertHorizontalWireAction"/> | <addaction name="insertHorizontalWireAction"/> | ||||
| <addaction name="insertVerticalWireAction"/> | <addaction name="insertVerticalWireAction"/> | ||||
| <addaction name="parallelInsertAction"/> | <addaction name="parallelInsertAction"/> | ||||
| @@ -353,6 +365,8 @@ | |||||
| <addaction name="addNormallyClosedAction"/> | <addaction name="addNormallyClosedAction"/> | ||||
| <addaction name="addNormalCoilAction"/> | <addaction name="addNormalCoilAction"/> | ||||
| <addaction name="editRungCommentAction"/> | <addaction name="editRungCommentAction"/> | ||||
| <addaction name="separator"/> | |||||
| <addaction name="syntaxCheckAction"/> | |||||
| </widget> | </widget> | ||||
| <widget class="QDockWidget" name="projectDock"> | <widget class="QDockWidget" name="projectDock"> | ||||
| <property name="minimumSize"> | <property name="minimumSize"> | ||||
| @@ -1139,12 +1153,47 @@ | |||||
| <string>在梯形图末尾新增网络</string> | <string>在梯形图末尾新增网络</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="insertRungAboveAction"> | |||||
| <property name="text"><string>上方插入行</string></property> | |||||
| <property name="toolTip"><string>在当前选中行的上方插入一条空白行,并保持竖线连接</string></property> | |||||
| </action> | |||||
| <action name="insertRungBelowAction"> | |||||
| <property name="text"><string>下方插入行</string></property> | |||||
| <property name="toolTip"><string>在当前选中行的下方插入一条空白行,并保持竖线连接</string></property> | |||||
| </action> | |||||
| <action name="deleteRungAction"> | |||||
| <property name="text"><string>删除行</string></property> | |||||
| <property name="toolTip"><string>删除当前选中行;上下同列竖线会自动合并</string></property> | |||||
| <property name="shortcut"><string>Shift+Delete</string></property> | |||||
| </action> | |||||
| <action name="mouseDrawWireAction"> | |||||
| <property name="checkable"> | |||||
| <bool>true</bool> | |||||
| </property> | |||||
| <property name="text"> | |||||
| <string>鼠标画线</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>拖动鼠标画横线或竖线;横线按网格补齐,竖线按相邻行分段连接</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="mouseEraseWireAction"> | |||||
| <property name="checkable"> | |||||
| <bool>true</bool> | |||||
| </property> | |||||
| <property name="text"> | |||||
| <string>鼠标删线</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>拖动鼠标删除经过的横线格;点击竖线只删除命中的连接段</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="insertHorizontalWireAction"> | <action name="insertHorizontalWireAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>横线</string> | <string>横线</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string> | |||||
| <string>在当前条件格插入横线并自动右移;空逻辑会先创建首行</string> | |||||
| </property> | </property> | ||||
| <property name="shortcut"> | <property name="shortcut"> | ||||
| <string>F11</string> | <string>F11</string> | ||||
| @@ -1155,7 +1204,7 @@ | |||||
| <string>竖线</string> | <string>竖线</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>为所选连续逻辑范围建立横线旁路和竖线连接</string> | |||||
| <string>在当前行与下一行的选中列边界建立一段竖线连接</string> | |||||
| </property> | </property> | ||||
| <property name="shortcut"> | <property name="shortcut"> | ||||
| <string>F12</string> | <string>F12</string> | ||||
| @@ -1177,7 +1226,7 @@ | |||||
| <string>删除竖线</string> | <string>删除竖线</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>删除画布中选中的竖线及其对应并联支路</string> | |||||
| <string>删除画布中选中的竖线连接,让网络在此处拆分</string> | |||||
| </property> | </property> | ||||
| <property name="shortcut"> | <property name="shortcut"> | ||||
| <string>Shift+F12</string> | <string>Shift+F12</string> | ||||
| @@ -1196,7 +1245,7 @@ | |||||
| <string>常开</string> | <string>常开</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string> | |||||
| <string>在当前条件格添加常开触点并自动右移;空逻辑会先创建首行</string> | |||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addNormallyClosedAction"> | <action name="addNormallyClosedAction"> | ||||
| @@ -1204,7 +1253,7 @@ | |||||
| <string>常闭</string> | <string>常闭</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string> | |||||
| <string>在当前条件格添加常闭触点并自动右移;空逻辑会先创建首行</string> | |||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addRisingEdgeAction"> | <action name="addRisingEdgeAction"> | ||||
| @@ -1277,10 +1326,29 @@ | |||||
| </action> | </action> | ||||
| <action name="deleteLogicAction"> | <action name="deleteLogicAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>删除节点/网络</string> | |||||
| <string>删除所选对象</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>删除选中的逻辑节点、横线格或竖线;整行请使用删除行</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="syntaxCheckAction"> | |||||
| <property name="text"> | |||||
| <string>语法检查</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>规整当前梯形图并检查输出路径</string> | |||||
| </property> | |||||
| <property name="shortcut"> | |||||
| <string>Ctrl+G</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="doubleCoilCheckAction"> | |||||
| <property name="text"> | |||||
| <string>双线圈检查</string> | |||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>删除选中的逻辑节点或网络</string> | |||||
| <string>检查当前梯形图中的重复 M 线圈输出</string> | |||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="editingModeAction"> | <action name="editingModeAction"> | ||||
| @@ -231,8 +231,7 @@ void RuntimeMonitorWidget::setLogicTrace( | |||||
| latest_trace_ = trace; | latest_trace_ = trace; | ||||
| latest_fault_node_id_ = fault_node_id; | latest_fault_node_id_ = fault_node_id; | ||||
| has_logic_trace_ = true; | has_logic_trace_ = true; | ||||
| logic_view_->setRuntimeTrace( | |||||
| trace.forLogic(selected_logic_id_), fault_node_id); | |||||
| logic_view_->setRuntimeTrace(trace, fault_node_id); | |||||
| } | } | ||||
| FreeMonitorWidget *RuntimeMonitorWidget::freeMonitorWidget() const | FreeMonitorWidget *RuntimeMonitorWidget::freeMonitorWidget() const | ||||
| @@ -265,7 +264,6 @@ void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id) | |||||
| logic_view_->reloadLogic(); | logic_view_->reloadLogic(); | ||||
| if (has_logic_trace_) | if (has_logic_trace_) | ||||
| { | { | ||||
| logic_view_->setRuntimeTrace( | |||||
| latest_trace_.forLogic(logic_id), latest_fault_node_id_); | |||||
| logic_view_->setRuntimeTrace(latest_trace_, latest_fault_node_id_); | |||||
| } | } | ||||
| } | } | ||||
| @@ -313,8 +313,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) | |||||
| hmi_navigation_service_.currentPageId(), logic_id); | hmi_navigation_service_.currentPageId(), logic_id); | ||||
| } | } | ||||
| logic_editor_widget_.setRuntimeTrace( | logic_editor_widget_.setRuntimeTrace( | ||||
| runtime_mode_service_.offlineSimulationService() | |||||
| .traceSnapshot().forLogic(logic_id), | |||||
| runtime_mode_service_.offlineSimulationService().traceSnapshot(), | |||||
| error.nodeId); | error.nodeId); | ||||
| if (runtime_monitor_widget_ != nullptr) | if (runtime_monitor_widget_ != nullptr) | ||||
| { | { | ||||
| @@ -393,7 +392,7 @@ void RuntimePanelController::handleScanCompleted() | |||||
| const LogicTraceSnapshot &trace = offline_running | const LogicTraceSnapshot &trace = offline_running | ||||
| ? runtime_mode_service_.offlineSimulationService().traceSnapshot() | ? runtime_mode_service_.offlineSimulationService().traceSnapshot() | ||||
| : runtime_mode_service_.onlineLogicMonitorService().traceSnapshot(); | : runtime_mode_service_.onlineLogicMonitorService().traceSnapshot(); | ||||
| logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id)); | |||||
| logic_editor_widget_.setRuntimeTrace(trace); | |||||
| if (runtime_monitor_widget_ != nullptr) | if (runtime_monitor_widget_ != nullptr) | ||||
| { | { | ||||
| runtime_monitor_widget_->setLogicTrace(trace); | runtime_monitor_widget_->setLogicTrace(trace); | ||||
| @@ -351,6 +351,23 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| painter.drawLine(QPointF(7, 12), QPointF(17, 12)); | painter.drawLine(QPointF(7, 12), QPointF(17, 12)); | ||||
| break; | break; | ||||
| } | } | ||||
| case UiIcon::MouseDrawWire: | |||||
| { | |||||
| painter.drawLine(QPointF(3, 16), QPointF(18, 16)); | |||||
| painter.drawLine(QPointF(18, 16), QPointF(18, 6)); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(6, 12), QPointF(6, 20)); | |||||
| painter.drawLine(QPointF(2, 16), QPointF(10, 16)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::MouseEraseWire: | |||||
| { | |||||
| painter.drawLine(QPointF(3, 16), QPointF(21, 16)); | |||||
| painter.drawLine(QPointF(15, 16), QPointF(15, 6)); | |||||
| painter.setPen(iconPen(kDanger, 2.2)); | |||||
| painter.drawLine(QPointF(6, 20), QPointF(20, 5)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::HorizontalWire: | case UiIcon::HorizontalWire: | ||||
| { | { | ||||
| painter.drawLine(QPointF(3, 12), QPointF(21, 12)); | painter.drawLine(QPointF(3, 12), QPointF(21, 12)); | ||||
| @@ -460,6 +477,16 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| painter.drawEllipse(QPointF(18, 12), 1.7, 1.7); | painter.drawEllipse(QPointF(18, 12), 1.7, 1.7); | ||||
| break; | break; | ||||
| } | } | ||||
| case UiIcon::SyntaxCheck: | |||||
| { | |||||
| painter.drawLine(QPointF(3, 3), QPointF(3, 21)); | |||||
| painter.drawLine(QPointF(3, 8), QPointF(13, 8)); | |||||
| painter.drawLine(QPointF(3, 16), QPointF(10, 16)); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(11, 15), QPointF(15, 19)); | |||||
| painter.drawLine(QPointF(15, 19), QPointF(22, 9)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::HmiPage: | case UiIcon::HmiPage: | ||||
| { | { | ||||
| painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5); | painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5); | ||||
| @@ -36,6 +36,8 @@ enum class UiIcon | |||||
| AlarmSettings, // 报警配置 | AlarmSettings, // 报警配置 | ||||
| Delete, // 删除 | Delete, // 删除 | ||||
| AddRung, // 新增网络 | AddRung, // 新增网络 | ||||
| MouseDrawWire, // 鼠标画线模式 | |||||
| MouseEraseWire, // 鼠标删线模式 | |||||
| HorizontalWire, // 横线 | HorizontalWire, // 横线 | ||||
| VerticalWire, // 竖线 | VerticalWire, // 竖线 | ||||
| ParallelBranch, // 并联支路 | ParallelBranch, // 并联支路 | ||||
| @@ -51,6 +53,7 @@ enum class UiIcon | |||||
| Subtract, // SUB 指令 | Subtract, // SUB 指令 | ||||
| Compare, // 比较指令 | Compare, // 比较指令 | ||||
| Comment, // 注释 | Comment, // 注释 | ||||
| SyntaxCheck, // 梯形图语法检查与规整 | |||||
| More, // 更多操作 | More, // 更多操作 | ||||
| HmiPage, // HMI 页面 | HmiPage, // HMI 页面 | ||||
| Logic, // 控制逻辑 | Logic, // 控制逻辑 | ||||
| @@ -13,7 +13,6 @@ | |||||
| #include <cmath> | #include <cmath> | ||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <exception> | #include <exception> | ||||
| #include <functional> | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <limits> | #include <limits> | ||||
| #include <set> | #include <set> | ||||
| @@ -24,30 +23,6 @@ namespace { | |||||
| using TestSupport::require; | using TestSupport::require; | ||||
| int expressionColumns(const ConditionExpression &expression) | |||||
| { | |||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| return 1; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| return expression.wire->columnSpan; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return expression.gap->columnSpan; | |||||
| } | |||||
| int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | |||||
| const int child_columns = expressionColumns(child); | |||||
| columns = expression.kind == ConditionExpressionKind::Series | |||||
| ? columns + child_columns : std::max(columns, child_columns); | |||||
| } | |||||
| return columns; | |||||
| } | |||||
| void testRegisterAddressBoundaries() | void testRegisterAddressBoundaries() | ||||
| { | { | ||||
| // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | ||||
| @@ -336,18 +311,20 @@ Project makeValidProject() | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-1"; | rung.id = "rung-1"; | ||||
| rung.name = "Network 1"; | rung.name = "Network 1"; | ||||
| ConditionExpression condition; | |||||
| condition.id = "start-series"; | |||||
| condition.kind = ConditionExpressionKind::Series; | |||||
| condition.children = { | |||||
| ConditionExpression::fromNode(contact), | |||||
| ConditionExpression::fromWire("start-wire", 9)}; | |||||
| rung.condition = std::move(condition); | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| rung.cells.push_back({ | |||||
| "start-cell-" + std::to_string(column), | |||||
| column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, | |||||
| column == 0 ? std::optional<LogicNode>{contact} : std::nullopt}); | |||||
| } | |||||
| rung.output = coil; | rung.output = coil; | ||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| Project project; | Project project; | ||||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | |||||
| project.metadata = {"sample-project", "Sample project", "2.0"}; | |||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.initialHmiPageId = page.id; | project.initialHmiPageId = page.id; | ||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||
| @@ -402,8 +379,10 @@ void testMultiPageAndLogicDomainRules() | |||||
| disabled_draft.id = "draft-logic"; | disabled_draft.id = "draft-logic"; | ||||
| disabled_draft.name = "Draft logic"; | disabled_draft.name = "Draft logic"; | ||||
| disabled_draft.enabled = false; | disabled_draft.enabled = false; | ||||
| disabled_draft.rungs.push_back( | |||||
| {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); | |||||
| LadderRung draft_rung; | |||||
| draft_rung.id = "rung-1"; | |||||
| draft_rung.name = "Draft network"; | |||||
| disabled_draft.rungs.push_back(std::move(draft_rung)); | |||||
| project.controlLogics.push_back(disabled_draft); | project.controlLogics.push_back(disabled_draft); | ||||
| require(project.validateForRunning(), | require(project.validateForRunning(), | ||||
| "a disabled draft logic must not block offline running"); | "a disabled draft logic must not block offline running"); | ||||
| @@ -521,60 +500,10 @@ void testQuantityBoundaries() | |||||
| project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; | project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; | ||||
| require(!project.validate(), "an HMI page height of 199 must be rejected"); | require(!project.validate(), "an HMI page height of 199 must be rejected"); | ||||
| ConditionExpression leaf = ConditionExpression::fromNode({ | |||||
| "depth-node-0", | |||||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}}, | |||||
| true}); | |||||
| std::function<ConditionExpression(int, int *)> makeNested = | |||||
| [&makeNested](int depth, int *next_address) | |||||
| { | |||||
| if (depth == 1) | |||||
| { | |||||
| const int address = (*next_address)++; | |||||
| return ConditionExpression::fromNode({ | |||||
| "depth-node-" + std::to_string(address), | |||||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, | |||||
| true}); | |||||
| } | |||||
| const int address = (*next_address)++; | |||||
| ConditionExpression nested = makeNested(depth - 1, next_address); | |||||
| ConditionExpression sibling = ConditionExpression::fromNode({ | |||||
| "depth-node-" + std::to_string(address), | |||||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, | |||||
| true}); | |||||
| ConditionExpression expression; | |||||
| expression.id = "depth-expression-" + std::to_string(address); | |||||
| expression.kind = depth % 2 == 0 | |||||
| ? ConditionExpressionKind::Parallel | |||||
| : ConditionExpressionKind::Series; | |||||
| if (expression.kind == ConditionExpressionKind::Parallel | |||||
| && expressionColumns(nested) > 1) | |||||
| { | |||||
| ConditionExpression padded_sibling; | |||||
| padded_sibling.id = "depth-padding-" + std::to_string(address); | |||||
| padded_sibling.kind = ConditionExpressionKind::Series; | |||||
| padded_sibling.children = { | |||||
| std::move(sibling), | |||||
| ConditionExpression::fromWire( | |||||
| "depth-wire-" + std::to_string(address), | |||||
| expressionColumns(nested) - 1)}; | |||||
| sibling = std::move(padded_sibling); | |||||
| } | |||||
| expression.children = {std::move(nested), std::move(sibling)}; | |||||
| return expression; | |||||
| }; | |||||
| int next_address = 1; | |||||
| ConditionExpression maximum_depth = makeNested( | |||||
| static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address); | |||||
| require(maximum_depth.validate(), | |||||
| "an expression depth at the configured limit must be accepted"); | |||||
| ConditionExpression excessive_depth = makeNested( | |||||
| static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1, | |||||
| &next_address); | |||||
| require(!excessive_depth.validate(), | |||||
| "an expression depth above the configured limit must be rejected"); | |||||
| (void)leaf; | |||||
| project = makeValidProject(); | |||||
| project.controlLogics.front().rungs.front().cells.pop_back(); | |||||
| require(!project.validate(), | |||||
| "a ladder row with fewer than ten cells must be rejected"); | |||||
| } | } | ||||
| void testLogicNodeConfigurationBoundaries() | void testLogicNodeConfigurationBoundaries() | ||||
| @@ -674,151 +603,106 @@ void testDataInstructionBoundaries() | |||||
| void testLadderLogicBoundaries() | void testLadderLogicBoundaries() | ||||
| { | { | ||||
| LogicNode stop; | |||||
| stop.id = "stop"; | |||||
| stop.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| ContactMode::NormallyClosed}; | |||||
| LogicNode start; | |||||
| start.id = "start"; | |||||
| start.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | |||||
| ContactMode::NormallyOpen}; | |||||
| LogicNode run_contact; | |||||
| run_contact.id = "run-contact"; | |||||
| run_contact.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| ContactMode::NormallyOpen}; | |||||
| LogicNode coil; | |||||
| coil.id = "run-coil"; | |||||
| coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| CoilMode::Normal}; | |||||
| ControlLogic logic; | ControlLogic logic; | ||||
| logic.id = "hold-logic"; | |||||
| logic.name = "Hold logic"; | |||||
| ConditionExpression start_parallel; | |||||
| start_parallel.id = "parallel-start"; | |||||
| start_parallel.kind = ConditionExpressionKind::Parallel; | |||||
| start_parallel.children = { | |||||
| ConditionExpression::fromNode(start), | |||||
| ConditionExpression::fromNode(run_contact)}; | |||||
| ConditionExpression root; | |||||
| root.id = "series-root"; | |||||
| root.kind = ConditionExpressionKind::Series; | |||||
| root.children = { | |||||
| ConditionExpression::fromNode(stop), | |||||
| start_parallel, | |||||
| ConditionExpression::fromWire("hold-output-wire", 8)}; | |||||
| LadderRung rung; | |||||
| rung.id = "rung-1"; | |||||
| rung.name = "Self hold"; | |||||
| rung.condition = root; | |||||
| rung.output = coil; | |||||
| logic.rungs.push_back(rung); | |||||
| require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid"); | |||||
| ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2); | |||||
| require(wire.validate() && wire.validateForRunning() | |||||
| && wire.wire->columnSpan == 2, | |||||
| "a configured horizontal wire must be a valid runnable expression leaf"); | |||||
| ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0); | |||||
| require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected"); | |||||
| invalid_wire = ConditionExpression::fromWire( | |||||
| "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | |||||
| require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); | |||||
| ConditionExpression gap = ConditionExpression::fromGap("gap-1", 1); | |||||
| require(gap.validate() && !gap.validateForRunning(), | |||||
| "a gap must be a valid editing draft but must block runtime validation"); | |||||
| ConditionExpression maximum_columns; | |||||
| maximum_columns.id = "maximum-columns"; | |||||
| maximum_columns.kind = ConditionExpressionKind::Series; | |||||
| for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) | |||||
| logic.id = "grid-logic"; | |||||
| logic.name = "Grid logic"; | |||||
| LadderRung upper; | |||||
| upper.id = "rung-1"; | |||||
| upper.name = "Row 1"; | |||||
| LadderRung lower; | |||||
| lower.id = "rung-2"; | |||||
| lower.name = "Row 2"; | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | { | ||||
| LogicNode node; | |||||
| node.id = "column-" + std::to_string(column + 1); | |||||
| node.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, column}, | |||||
| ContactMode::NormallyOpen}; | |||||
| maximum_columns.children.push_back( | |||||
| ConditionExpression::fromNode(std::move(node))); | |||||
| upper.cells.push_back({ | |||||
| "upper-cell-" + std::to_string(column), | |||||
| LadderCellKind::Wire, | |||||
| std::nullopt}); | |||||
| lower.cells.push_back({ | |||||
| "lower-cell-" + std::to_string(column), | |||||
| LadderCellKind::Gap, | |||||
| std::nullopt}); | |||||
| } | } | ||||
| require(maximum_columns.validate(), | |||||
| "ten condition columns must be accepted"); | |||||
| LogicNode extra_column; | |||||
| extra_column.id = "column-11"; | |||||
| extra_column.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, | |||||
| ContactMode::NormallyOpen}; | |||||
| maximum_columns.children.push_back( | |||||
| ConditionExpression::fromNode(std::move(extra_column))); | |||||
| require(!maximum_columns.validate(), | |||||
| "an eleventh condition column must be rejected"); | |||||
| ConditionExpression wired_series; | |||||
| wired_series.id = "wired-series"; | |||||
| wired_series.kind = ConditionExpressionKind::Series; | |||||
| wired_series.children = { | |||||
| ConditionExpression::fromNode(stop), | |||||
| ConditionExpression::fromWire("wire-series"), | |||||
| start_parallel}; | |||||
| require(wired_series.validateForRunning(), | |||||
| "a wire must preserve a valid structured series expression"); | |||||
| logic.rungs.front().condition->children.front() = | |||||
| ConditionExpression::fromNode(coil); | |||||
| require(!logic.validate(), "a ladder condition expression must reject coils"); | |||||
| logic.rungs.front().condition = root; | |||||
| logic.rungs.front().output = start; | |||||
| require(!logic.validate(), "a ladder output must be a coil"); | |||||
| upper.cells[0].kind = LadderCellKind::Node; | |||||
| upper.cells[0].node = LogicNode{ | |||||
| "start", | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | |||||
| ContactMode::NormallyOpen}, | |||||
| true}; | |||||
| upper.output = LogicNode{ | |||||
| "run-coil", | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal}, | |||||
| true}; | |||||
| logic.rungs = {upper, lower}; | |||||
| logic.verticalConnections = { | |||||
| {"vertical-left", "rung-1", "rung-2", 0}, | |||||
| {"vertical-right", "rung-1", "rung-2", 1}}; | |||||
| require(logic.validate() && logic.validateForRunning(), | |||||
| "a ten-cell grid with adjacent vertical edges must be valid"); | |||||
| logic.rungs.front().cells[5].kind = LadderCellKind::Gap; | |||||
| std::string connectivity_error; | |||||
| require( | |||||
| logic.validate() && !logic.validateForRunning(&connectivity_error) | |||||
| && connectivity_error.find("第 1 行") != std::string::npos | |||||
| && connectivity_error.find("第 6 列") != std::string::npos, | |||||
| "a disconnected output must report its visual row and break column"); | |||||
| logic.rungs.front() = upper; | |||||
| logic.rungs.front().cells[5].kind = LadderCellKind::Gap; | |||||
| for (LadderCell &cell : logic.rungs.back().cells) | |||||
| { | |||||
| cell.kind = LadderCellKind::Wire; | |||||
| cell.node.reset(); | |||||
| } | |||||
| logic.verticalConnections = { | |||||
| {"vertical-left", "rung-1", "rung-2", 0}, | |||||
| {"vertical-bypass", "rung-1", "rung-2", 6}}; | |||||
| require( | |||||
| logic.validateForRunning(), | |||||
| "a vertical branch that bypasses a gap must keep the output reachable"); | |||||
| logic.rungs = {upper, lower}; | |||||
| logic.verticalConnections = { | |||||
| {"vertical-left", "rung-1", "rung-2", 0}, | |||||
| {"vertical-right", "rung-1", "rung-2", 1}}; | |||||
| logic.rungs.front().cells.front().node = LogicNode{ | |||||
| "invalid-coil", | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 2}, CoilMode::Normal}, | |||||
| true}; | |||||
| require(!logic.validate(), "a condition cell must reject output nodes"); | |||||
| logic.rungs.front() = upper; | |||||
| logic.rungs.front().output = LogicNode{ | |||||
| "invalid-contact", | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 2}, ContactMode::NormallyOpen}, | |||||
| true}; | |||||
| require(!logic.validate(), "the output slot must reject condition nodes"); | |||||
| logic.rungs.front() = upper; | |||||
| logic.rungs.front().output.reset(); | logic.rungs.front().output.reset(); | ||||
| require(logic.validate(), "incomplete ladder may remain in an editable draft"); | |||||
| require(!logic.validateForRunning(), | |||||
| "conditions without an output must block runtime validation"); | |||||
| LadderRung empty_rung{ | |||||
| "rung-empty", "Empty network", {}, std::nullopt, std::nullopt}; | |||||
| require(empty_rung.validate(), "an empty editing network must be valid"); | |||||
| empty_rung.output = coil; | |||||
| require(!empty_rung.validate(), | |||||
| "an output without an explicit ten-column condition path must be rejected"); | |||||
| empty_rung.condition = ConditionExpression::fromWire("unconditional-wire", 10); | |||||
| require(empty_rung.validate() && empty_rung.validateForRunning(), | |||||
| "a ten-column wire path must represent a runnable unconditional rung"); | |||||
| empty_rung.condition = ConditionExpression::fromGap("unconditional-gap", 10); | |||||
| require(empty_rung.validate() && !empty_rung.validateForRunning(), | |||||
| "a full-width gap draft must remain saved but disconnected"); | |||||
| logic.rungs.front().output = coil; | |||||
| logic.rungs.front().condition = root; | |||||
| logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id; | |||||
| require(!logic.validate(), "logic node ids must be unique"); | |||||
| ConditionExpression nested_parallel; | |||||
| nested_parallel.id = "parallel-nested"; | |||||
| nested_parallel.kind = ConditionExpressionKind::Parallel; | |||||
| nested_parallel.children = { | |||||
| ConditionExpression::fromNode(start), | |||||
| root}; | |||||
| require(!nested_parallel.validate(), | |||||
| "parallel branches with unequal explicit widths must be rejected"); | |||||
| ConditionExpression padded_start; | |||||
| padded_start.id = "padded-start"; | |||||
| padded_start.kind = ConditionExpressionKind::Series; | |||||
| padded_start.children = { | |||||
| ConditionExpression::fromNode(start), | |||||
| ConditionExpression::fromWire("nested-parallel-wire", 9)}; | |||||
| nested_parallel.children.front() = std::move(padded_start); | |||||
| require(nested_parallel.validate(), | |||||
| "parallel branches padded with explicit wires must be valid"); | |||||
| require(logic.validate() && logic.validateForRunning(), | |||||
| "a row without an output may act as a connected branch"); | |||||
| logic.rungs.front() = upper; | |||||
| logic.rungs.front().cells[1].id = logic.rungs.front().cells[0].id; | |||||
| require(!logic.validate(), "cell ids must be unique within a logic"); | |||||
| logic.rungs.front() = upper; | |||||
| logic.verticalConnections.front().lowerRungId = "missing-rung"; | |||||
| require(!logic.validate(), "vertical edges must reference adjacent rows"); | |||||
| logic.verticalConnections = { | |||||
| {"vertical-left", "rung-1", "rung-2", 0}, | |||||
| {"vertical-copy", "rung-1", "rung-2", 0}}; | |||||
| require(!logic.validate(), | |||||
| "one row boundary must not contain duplicate vertical edges"); | |||||
| } | } | ||||
| void testModelsValidateBindingsAndIdentifiers() | void testModelsValidateBindingsAndIdentifiers() | ||||
| @@ -101,58 +101,21 @@ LadderRung rung(const std::string &id, | |||||
| LadderRung result; | LadderRung result; | ||||
| result.id = id; | result.id = id; | ||||
| result.name = id; | result.name = id; | ||||
| std::vector<ConditionExpression> series_children; | |||||
| for (std::size_t index = 0; index < stages.size(); ++index) | |||||
| result.cells.reserve(ProjectLimits::kMaximumConditionColumns); | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | { | ||||
| std::vector<ConditionExpression> parallel_children; | |||||
| for (const LogicNode &node : stages[index]) | |||||
| LadderCell cell; | |||||
| cell.id = id + "-cell-" + std::to_string(column); | |||||
| cell.kind = LadderCellKind::Wire; | |||||
| if (column < static_cast<int>(stages.size()) | |||||
| && !stages[static_cast<std::size_t>(column)].empty()) | |||||
| { | { | ||||
| parallel_children.push_back(ConditionExpression::fromNode(node)); | |||||
| } | |||||
| if (parallel_children.size() == 1U) | |||||
| { | |||||
| series_children.push_back(std::move(parallel_children.front())); | |||||
| } | |||||
| else | |||||
| { | |||||
| ConditionExpression parallel; | |||||
| parallel.id = id + "-parallel-" + std::to_string(index); | |||||
| parallel.kind = ConditionExpressionKind::Parallel; | |||||
| parallel.children = std::move(parallel_children); | |||||
| series_children.push_back(std::move(parallel)); | |||||
| } | |||||
| } | |||||
| if (series_children.size() == 1U) | |||||
| { | |||||
| result.condition = std::move(series_children.front()); | |||||
| } | |||||
| else | |||||
| { | |||||
| ConditionExpression series; | |||||
| series.id = id + "-series"; | |||||
| series.kind = ConditionExpressionKind::Series; | |||||
| series.children = std::move(series_children); | |||||
| result.condition = std::move(series); | |||||
| } | |||||
| const int occupied_columns = static_cast<int>(stages.size()); | |||||
| if (occupied_columns < ProjectLimits::kMaximumConditionColumns) | |||||
| { | |||||
| ConditionExpression wire = ConditionExpression::fromWire( | |||||
| id + "-output-wire", | |||||
| ProjectLimits::kMaximumConditionColumns - occupied_columns); | |||||
| if (result.condition->kind == ConditionExpressionKind::Series) | |||||
| { | |||||
| result.condition->children.push_back(std::move(wire)); | |||||
| } | |||||
| else | |||||
| { | |||||
| ConditionExpression series; | |||||
| series.id = id + "-explicit-series"; | |||||
| series.kind = ConditionExpressionKind::Series; | |||||
| series.children.push_back(std::move(*result.condition)); | |||||
| series.children.push_back(std::move(wire)); | |||||
| result.condition = std::move(series); | |||||
| cell.kind = LadderCellKind::Node; | |||||
| cell.node = stages[static_cast<std::size_t>(column)].front(); | |||||
| } | } | ||||
| result.cells.push_back(std::move(cell)); | |||||
| } | } | ||||
| result.output = output; | result.output = output; | ||||
| return result; | return result; | ||||
| @@ -191,58 +154,42 @@ std::int16_t readWord(RegisterRepository &repository, int address) | |||||
| return result.value; | return result.value; | ||||
| } | } | ||||
| void testNestedSeriesParallelExpression() | |||||
| void testParallelRowsAndColumnPropagation() | |||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| SoftwareLogicExecutor executor; | SoftwareLogicExecutor executor; | ||||
| ConditionExpression nested_series; | |||||
| nested_series.id = "nested-series"; | |||||
| nested_series.kind = ConditionExpressionKind::Series; | |||||
| nested_series.children = { | |||||
| ConditionExpression::fromNode(contact("b", 1)), | |||||
| ConditionExpression::fromNode(contact("c", 2))}; | |||||
| ConditionExpression padded_a; | |||||
| padded_a.id = "padded-a"; | |||||
| padded_a.kind = ConditionExpressionKind::Series; | |||||
| padded_a.children = { | |||||
| ConditionExpression::fromNode(contact("a", 0)), | |||||
| ConditionExpression::fromWire("a-branch-wire", 1)}; | |||||
| ConditionExpression parallel; | |||||
| parallel.id = "root-parallel"; | |||||
| parallel.kind = ConditionExpressionKind::Parallel; | |||||
| parallel.children = {std::move(padded_a), nested_series}; | |||||
| ConditionExpression root; | |||||
| root.id = "nested-output-series"; | |||||
| root.kind = ConditionExpressionKind::Series; | |||||
| root.children = { | |||||
| std::move(parallel), | |||||
| ConditionExpression::fromWire("nested-output-wire", 8)}; | |||||
| LadderRung nested_rung; | |||||
| nested_rung.id = "nested-rung"; | |||||
| nested_rung.name = "nested-rung"; | |||||
| nested_rung.condition = root; | |||||
| nested_rung.output = coil("nested-output", 10); | |||||
| const ControlLogic program = logic({nested_rung}); | |||||
| LadderRung first = rung( | |||||
| "first", {{contact("a", 0)}}, | |||||
| coil("first-output", 10)); | |||||
| LadderRung second = rung( | |||||
| "second", {{contact("c", 2)}}, | |||||
| coil("second-output", 11)); | |||||
| second.output.reset(); | |||||
| ControlLogic program = logic({first, second}); | |||||
| program.verticalConnections = { | |||||
| {"left-bridge", "first", "second", 0}, | |||||
| {"right-bridge", "first", "second", 1}}; | |||||
| writeBit(repository, 1, true); | |||||
| writeBit(repository, 2, true); | |||||
| writeBit(repository, 0, true); | |||||
| LogicTraceSnapshot trace; | LogicTraceSnapshot trace; | ||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | require(executor.executeScan({program}, repository, &trace).succeeded, | ||||
| "nested expression scan must succeed"); | |||||
| require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)"); | |||||
| require(trace.expressionValues.at("nested-series") | |||||
| && trace.expressionValues.at("root-parallel") | |||||
| && trace.rungValues.at("nested-rung"), | |||||
| "scan trace must expose active nested expression and rung values"); | |||||
| "parallel row scan must succeed"); | |||||
| require(readBit(repository, 10), "the first row must energize its output"); | |||||
| writeBit(repository, 2, false); | |||||
| writeBit(repository, 0, false); | |||||
| writeBit(repository, 2, true); | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | require(executor.executeScan({program}, repository, &trace).succeeded, | ||||
| "nested false scan must succeed"); | |||||
| require(!readBit(repository, 10), "incomplete B AND C branch must be false"); | |||||
| writeBit(repository, 0, true); | |||||
| "second parallel row scan must succeed"); | |||||
| require(readBit(repository, 10), | |||||
| "the lower branch must feed the shared output through the right edge"); | |||||
| require(trace.verticalConnectionValues.at("left-bridge"), | |||||
| "vertical connection trace must expose the boundary power"); | |||||
| program.verticalConnections.pop_back(); | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | require(executor.executeScan({program}, repository, &trace).succeeded, | ||||
| "alternate branch scan must succeed"); | |||||
| require(readBit(repository, 10), "A branch must independently energize output"); | |||||
| "the split network scan must succeed"); | |||||
| require(!readBit(repository, 10), | |||||
| "deleting the right edge must split the branch from the output"); | |||||
| } | } | ||||
| void testUnconditionalCoil() | void testUnconditionalCoil() | ||||
| @@ -252,8 +199,15 @@ void testUnconditionalCoil() | |||||
| LadderRung unconditional; | LadderRung unconditional; | ||||
| unconditional.id = "unconditional-rung"; | unconditional.id = "unconditional-rung"; | ||||
| unconditional.name = "unconditional-rung"; | unconditional.name = "unconditional-rung"; | ||||
| unconditional.condition = ConditionExpression::fromWire( | |||||
| "unconditional-wire", ProjectLimits::kMaximumConditionColumns); | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| unconditional.cells.push_back({ | |||||
| "unconditional-cell-" + std::to_string(column), | |||||
| LadderCellKind::Wire, | |||||
| std::nullopt}); | |||||
| } | |||||
| unconditional.output = coil("unconditional-coil", 10); | unconditional.output = coil("unconditional-coil", 10); | ||||
| const ControlLogic program = logic({unconditional}); | const ControlLogic program = logic({unconditional}); | ||||
| @@ -264,27 +218,52 @@ void testUnconditionalCoil() | |||||
| "a full-width wire network scan must succeed"); | "a full-width wire network scan must succeed"); | ||||
| require(readBit(repository, 10), | require(readBit(repository, 10), | ||||
| "a full-width wire network must energize its coil as a constant-true rung"); | "a full-width wire network must energize its coil as a constant-true rung"); | ||||
| require(trace.rungValues.at("unconditional-rung") | |||||
| && trace.nodePowerValues.at("unconditional-coil"), | |||||
| require(trace.rungValues.at("unconditional-rung"), | |||||
| "an unconditional rung must report energized power flow"); | "an unconditional rung must report energized power flow"); | ||||
| } | } | ||||
| void testEnabledEmptyRowIsRejectedBeforeScanning() | |||||
| { | |||||
| SoftwareLogicExecutor executor; | |||||
| LadderRung empty; | |||||
| empty.id = "empty-enabled-rung"; | |||||
| empty.name = "Empty enabled rung"; | |||||
| const ControlLogic program = logic({empty}); | |||||
| const LogicScanResult validation = executor.validate({program}); | |||||
| require(!validation.succeeded | |||||
| && validation.error == LogicScanError::InvalidLogic, | |||||
| "an enabled empty row must be rejected before fixed-grid scanning"); | |||||
| } | |||||
| void testWirePassThroughAndPowerTrace() | void testWirePassThroughAndPowerTrace() | ||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| SoftwareLogicExecutor executor; | SoftwareLogicExecutor executor; | ||||
| ConditionExpression root; | |||||
| root.id = "wire-series"; | |||||
| root.kind = ConditionExpressionKind::Series; | |||||
| root.children = { | |||||
| ConditionExpression::fromNode(contact("wire-input", 0)), | |||||
| ConditionExpression::fromWire("wire-segment", 2), | |||||
| ConditionExpression::fromNode(contact("wire-output", 1)), | |||||
| ConditionExpression::fromWire("wire-output-padding", 6)}; | |||||
| LadderRung wired_rung; | LadderRung wired_rung; | ||||
| wired_rung.id = "wired-rung"; | wired_rung.id = "wired-rung"; | ||||
| wired_rung.name = "wired-rung"; | wired_rung.name = "wired-rung"; | ||||
| wired_rung.condition = root; | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| wired_rung.cells.push_back({ | |||||
| "wire-cell-" + std::to_string(column), | |||||
| LadderCellKind::Gap, | |||||
| std::nullopt}); | |||||
| } | |||||
| wired_rung.cells[0].kind = LadderCellKind::Node; | |||||
| wired_rung.cells[0].node = contact("wire-input", 0); | |||||
| wired_rung.cells[1].kind = LadderCellKind::Wire; | |||||
| wired_rung.cells[2].kind = LadderCellKind::Wire; | |||||
| wired_rung.cells[3].kind = LadderCellKind::Node; | |||||
| wired_rung.cells[3].node = contact("wire-output", 1); | |||||
| for (int column = 4; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| wired_rung.cells[static_cast<std::size_t>(column)].kind = | |||||
| LadderCellKind::Wire; | |||||
| } | |||||
| wired_rung.output = coil("wired-coil", 10); | wired_rung.output = coil("wired-coil", 10); | ||||
| const ControlLogic program = logic({wired_rung}); | const ControlLogic program = logic({wired_rung}); | ||||
| @@ -294,17 +273,17 @@ void testWirePassThroughAndPowerTrace() | |||||
| "wire expression scan must succeed"); | "wire expression scan must succeed"); | ||||
| require(!readBit(repository, 10), | require(!readBit(repository, 10), | ||||
| "a horizontal wire must not bypass a false upstream series contact"); | "a horizontal wire must not bypass a false upstream series contact"); | ||||
| require(trace.expressionValues.at("wire-segment") | |||||
| && !trace.expressionInputValues.at("wire-segment") | |||||
| && !trace.expressionPowerValues.at("wire-segment"), | |||||
| require(trace.cellValues.at("wire-cell-1") | |||||
| && !trace.cellInputPowerValues.at("wire-cell-1") | |||||
| && !trace.cellPowerValues.at("wire-cell-1"), | |||||
| "a wire must remain logically true without showing false upstream power"); | "a wire must remain logically true without showing false upstream power"); | ||||
| writeBit(repository, 0, true); | writeBit(repository, 0, true); | ||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | require(executor.executeScan({program}, repository, &trace).succeeded, | ||||
| "powered wire expression scan must succeed"); | "powered wire expression scan must succeed"); | ||||
| require(readBit(repository, 10) | require(readBit(repository, 10) | ||||
| && trace.expressionInputValues.at("wire-segment") | |||||
| && trace.expressionPowerValues.at("wire-segment"), | |||||
| && trace.cellInputPowerValues.at("wire-cell-1") | |||||
| && trace.cellPowerValues.at("wire-cell-1"), | |||||
| "a powered horizontal wire must pass current to the downstream contact"); | "a powered horizontal wire must pass current to the downstream contact"); | ||||
| } | } | ||||
| @@ -312,12 +291,20 @@ void testSeriesParallelContactsAndSequentialVisibility() | |||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| SoftwareLogicExecutor executor; | SoftwareLogicExecutor executor; | ||||
| const ControlLogic program = logic({ | |||||
| rung("rung-1", | |||||
| {{contact("start", 0), contact("alternate", 1)}, | |||||
| {contact("stop", 2, ContactMode::NormallyClosed)}}, | |||||
| coil("run", 3)), | |||||
| rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))}); | |||||
| LadderRung primary = rung( | |||||
| "rung-1", | |||||
| {{contact("start", 0)}, | |||||
| {contact("stop-primary", 2, ContactMode::NormallyClosed)}}, | |||||
| coil("run", 3)); | |||||
| LadderRung alternate = rung( | |||||
| "rung-2", | |||||
| {{contact("alternate", 1)}, | |||||
| {contact("stop-alternate", 2, ContactMode::NormallyClosed)}}, | |||||
| coil("downstream", 4)); | |||||
| ControlLogic program = logic({primary, alternate}); | |||||
| program.verticalConnections = { | |||||
| {"parallel-left", "rung-1", "rung-2", 0}, | |||||
| {"parallel-right", "rung-1", "rung-2", 2}}; | |||||
| writeBit(repository, 1, true); | writeBit(repository, 1, true); | ||||
| require(executor.executeScan({program}, repository).succeeded, | require(executor.executeScan({program}, repository).succeeded, | ||||
| @@ -333,6 +320,106 @@ void testSeriesParallelContactsAndSequentialVisibility() | |||||
| require(!readBit(repository, 4), "downstream normal coil must follow the new value"); | require(!readBit(repository, 4), "downstream normal coil must follow the new value"); | ||||
| } | } | ||||
| void testMotorForwardReverseSelfHoldAndInterlockTruthTable() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| LadderRung forward = rung( | |||||
| "forward-rung", | |||||
| {{contact("forward-start", 0)}, | |||||
| {contact("forward-stop", 2, ContactMode::NormallyClosed)}, | |||||
| {contact("forward-interlock", 11, ContactMode::NormallyClosed)}}, | |||||
| coil("forward-coil", 10)); | |||||
| LadderRung forward_hold = rung( | |||||
| "forward-hold-rung", | |||||
| {{contact("forward-hold", 10)}, | |||||
| {contact("forward-hold-stop", 2, ContactMode::NormallyClosed)}, | |||||
| {contact("forward-hold-interlock", 11, ContactMode::NormallyClosed)}}, | |||||
| coil("unused-forward-branch-output", 20)); | |||||
| forward_hold.output.reset(); | |||||
| LadderRung reverse = rung( | |||||
| "reverse-rung", | |||||
| {{contact("reverse-start", 1)}, | |||||
| {contact("reverse-stop", 2, ContactMode::NormallyClosed)}, | |||||
| {contact("reverse-interlock", 10, ContactMode::NormallyClosed)}}, | |||||
| coil("reverse-coil", 11)); | |||||
| LadderRung reverse_hold = rung( | |||||
| "reverse-hold-rung", | |||||
| {{contact("reverse-hold", 11)}, | |||||
| {contact("reverse-hold-stop", 2, ContactMode::NormallyClosed)}, | |||||
| {contact("reverse-hold-interlock", 10, ContactMode::NormallyClosed)}}, | |||||
| coil("unused-reverse-branch-output", 21)); | |||||
| reverse_hold.output.reset(); | |||||
| ControlLogic program = logic({ | |||||
| forward, forward_hold, reverse, reverse_hold}); | |||||
| program.id = "motor-control-logic"; | |||||
| program.verticalConnections = { | |||||
| {"forward-left", "forward-rung", "forward-hold-rung", 0}, | |||||
| {"forward-right", "forward-rung", "forward-hold-rung", 10}, | |||||
| {"reverse-left", "reverse-rung", "reverse-hold-rung", 0}, | |||||
| {"reverse-right", "reverse-rung", "reverse-hold-rung", 10}}; | |||||
| LogicTraceSnapshot trace; | |||||
| const auto scan = [&] | |||||
| { | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||||
| "the motor truth-table scan must succeed"); | |||||
| }; | |||||
| scan(); | |||||
| require(!readBit(repository, 10) && !readBit(repository, 11), | |||||
| "both motor directions must be off in the stopped state"); | |||||
| require( | |||||
| trace.cellInputPowerValues.at("forward-rung-cell-0") | |||||
| && !trace.cellPowerValues.at("forward-rung-cell-0") | |||||
| && !trace.rungValues.at("forward-rung"), | |||||
| "a false start contact must keep only its left terminal energized"); | |||||
| writeBit(repository, 0, true); | |||||
| scan(); | |||||
| require(readBit(repository, 10) && !readBit(repository, 11), | |||||
| "the forward start input must energize only M10"); | |||||
| require( | |||||
| trace.cellInputPowerValues.at("forward-rung-cell-0") | |||||
| && trace.cellPowerValues.at("forward-rung-cell-0") | |||||
| && trace.verticalConnectionValues.at("forward-right") | |||||
| && trace.nodeValues.at("forward-coil"), | |||||
| "the forward trace must reach the output and connected branch edge"); | |||||
| writeBit(repository, 0, false); | |||||
| scan(); | |||||
| require(readBit(repository, 10) && !readBit(repository, 11), | |||||
| "M10 must remain energized through the forward self-hold branch"); | |||||
| writeBit(repository, 1, true); | |||||
| scan(); | |||||
| require(readBit(repository, 10) && !readBit(repository, 11), | |||||
| "the reverse start input must be blocked while forward is active"); | |||||
| writeBit(repository, 2, true); | |||||
| scan(); | |||||
| require(!readBit(repository, 10) && !readBit(repository, 11), | |||||
| "the stop input must release both direction outputs"); | |||||
| writeBit(repository, 1, false); | |||||
| writeBit(repository, 2, false); | |||||
| scan(); | |||||
| writeBit(repository, 1, true); | |||||
| scan(); | |||||
| require(!readBit(repository, 10) && readBit(repository, 11), | |||||
| "the reverse start input must energize only M11 after stopping"); | |||||
| writeBit(repository, 1, false); | |||||
| scan(); | |||||
| require(!readBit(repository, 10) && readBit(repository, 11), | |||||
| "M11 must remain energized through the reverse self-hold branch"); | |||||
| writeBit(repository, 0, true); | |||||
| scan(); | |||||
| require(!readBit(repository, 10) && readBit(repository, 11), | |||||
| "the forward start input must be blocked while reverse is active"); | |||||
| } | |||||
| void testAllComparisons() | void testAllComparisons() | ||||
| { | { | ||||
| const std::array<ComparisonOperator, 6> operations{ | const std::array<ComparisonOperator, 6> operations{ | ||||
| @@ -412,8 +499,10 @@ void testMultipleLogicScanOrderAndTraceIsolation() | |||||
| disabled_draft.id = "logic-draft"; | disabled_draft.id = "logic-draft"; | ||||
| disabled_draft.name = "Draft"; | disabled_draft.name = "Draft"; | ||||
| disabled_draft.enabled = false; | disabled_draft.enabled = false; | ||||
| disabled_draft.rungs.push_back( | |||||
| {"rung-1", "Draft", {}, std::nullopt, std::nullopt}); | |||||
| LadderRung draft_rung; | |||||
| draft_rung.id = "rung-1"; | |||||
| draft_rung.name = "Draft"; | |||||
| disabled_draft.rungs.push_back(std::move(draft_rung)); | |||||
| require(executor.validate({first, disabled_draft}).succeeded, | require(executor.validate({first, disabled_draft}).succeeded, | ||||
| "a disabled incomplete logic module must not block offline execution"); | "a disabled incomplete logic module must not block offline execution"); | ||||
| } | } | ||||
| @@ -614,11 +703,22 @@ void testHmiSimulationClosedLoop() | |||||
| indicator.bounds = {0, 40, 80, 30}; | indicator.bounds = {0, 40, 80, 30}; | ||||
| indicator.text = "run"; | indicator.text = "run"; | ||||
| indicator.binding = RegisterAddress{RegisterArea::M, 2}; | indicator.binding = RegisterAddress{RegisterArea::M, 2}; | ||||
| const ControlLogic program = logic({ | |||||
| rung("hold-rung", | |||||
| {{contact("stop", 1, ContactMode::NormallyClosed)}, | |||||
| {contact("start", 0), contact("feedback", 2)}}, | |||||
| coil("run", 2))}); | |||||
| LadderRung start_path = rung( | |||||
| "hold-start", | |||||
| {{contact("stop", 1, ContactMode::NormallyClosed)}, | |||||
| {contact("start", 0)}}, | |||||
| coil("run", 2)); | |||||
| LadderRung feedback_path = rung( | |||||
| "hold-feedback", | |||||
| {{contact("feedback-padding", 4000)}, {contact("feedback", 2)}}, | |||||
| coil("unused-output", 4000)); | |||||
| feedback_path.cells[0].kind = LadderCellKind::Gap; | |||||
| feedback_path.cells[0].node.reset(); | |||||
| feedback_path.output.reset(); | |||||
| ControlLogic program = logic({start_path, feedback_path}); | |||||
| program.verticalConnections = { | |||||
| {"hold-left", "hold-start", "hold-feedback", 1}, | |||||
| {"hold-right", "hold-start", "hold-feedback", 2}}; | |||||
| require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded, | require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded, | ||||
| "HMI start button press must write virtual M"); | "HMI start button press must write virtual M"); | ||||
| @@ -779,9 +879,11 @@ int main(int argc, char *argv[]) | |||||
| try | try | ||||
| { | { | ||||
| testSeriesParallelContactsAndSequentialVisibility(); | testSeriesParallelContactsAndSequentialVisibility(); | ||||
| testNestedSeriesParallelExpression(); | |||||
| testParallelRowsAndColumnPropagation(); | |||||
| testUnconditionalCoil(); | testUnconditionalCoil(); | ||||
| testEnabledEmptyRowIsRejectedBeforeScanning(); | |||||
| testWirePassThroughAndPowerTrace(); | testWirePassThroughAndPowerTrace(); | ||||
| testMotorForwardReverseSelfHoldAndInterlockTruthTable(); | |||||
| testAllComparisons(); | testAllComparisons(); | ||||
| testSetResetAndDisabledLogic(); | testSetResetAndDisabledLogic(); | ||||
| testMultipleLogicScanOrderAndTraceIsolation(); | testMultipleLogicScanOrderAndTraceIsolation(); | ||||
| @@ -36,20 +36,24 @@ ControlLogic makeLogic(int index) | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-" + std::to_string(index); | rung.id = "rung-" + std::to_string(index); | ||||
| rung.name = rung.id; | rung.name = rung.id; | ||||
| ConditionExpression condition; | |||||
| condition.id = "series-" + std::to_string(index); | |||||
| condition.kind = ConditionExpressionKind::Series; | |||||
| condition.children = { | |||||
| ConditionExpression::fromNode( | |||||
| contact("contact-" + std::to_string(index), index)), | |||||
| ConditionExpression::fromWire( | |||||
| "wire-" + std::to_string(index), 9)}; | |||||
| rung.condition = std::move(condition); | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| rung.cells.push_back({ | |||||
| rung.id + "-cell-" + std::to_string(column), | |||||
| column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, | |||||
| column == 0 | |||||
| ? std::optional<LogicNode>{contact( | |||||
| "contact-" + std::to_string(index), index)} | |||||
| : std::nullopt}); | |||||
| } | |||||
| rung.output = coil("coil-" + std::to_string(index), index + 100); | rung.output = coil("coil-" + std::to_string(index), index + 100); | ||||
| return {"logic-" + std::to_string(index), | return {"logic-" + std::to_string(index), | ||||
| "logic-" + std::to_string(index), | "logic-" + std::to_string(index), | ||||
| {rung}, | {rung}, | ||||
| true}; | |||||
| true, | |||||
| {}}; | |||||
| } | } | ||||
| class PerformanceTests final : public QObject | class PerformanceTests final : public QObject | ||||
| @@ -11,6 +11,7 @@ | |||||
| #include "services/plc_discovery_gateway.h" | #include "services/plc_discovery_gateway.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "services/logic_editor_service.h" | |||||
| #include <functional> | #include <functional> | ||||
| #include <iostream> | #include <iostream> | ||||
| @@ -187,15 +188,20 @@ void testRuntimeRepositorySwitchingAndDisconnect() | |||||
| indicator.text = "Run"; | indicator.text = "Run"; | ||||
| indicator.binding = RegisterAddress{RegisterArea::M, 5}; | indicator.binding = RegisterAddress{RegisterArea::M, 5}; | ||||
| page.controls.push_back(indicator); | page.controls.push_back(indicator); | ||||
| project_service.editProject().initialHmiPageId = page.id; | |||||
| project_service.editProject().hmiPages.push_back(page); | project_service.editProject().hmiPages.push_back(page); | ||||
| VirtualRegisterRepository virtual_repository; | VirtualRegisterRepository virtual_repository; | ||||
| PlcRegisterRepository plc_repository; | PlcRegisterRepository plc_repository; | ||||
| ActiveRegisterRepository active_repository(virtual_repository); | ActiveRegisterRepository active_repository(virtual_repository); | ||||
| OfflineSimulationService simulation_service(virtual_repository); | OfflineSimulationService simulation_service(virtual_repository); | ||||
| OnlineLogicMonitorService online_monitor_service(plc_repository); | OnlineLogicMonitorService online_monitor_service(plc_repository); | ||||
| LogicEditorService logic_editor_service(project_service); | |||||
| FakePlcGateway gateway; | FakePlcGateway gateway; | ||||
| RuntimeModeService service( | RuntimeModeService service( | ||||
| project_service, simulation_service, online_monitor_service); | |||||
| project_service, | |||||
| logic_editor_service, | |||||
| simulation_service, | |||||
| online_monitor_service); | |||||
| service.configurePlc( | service.configurePlc( | ||||
| gateway, active_repository, virtual_repository, plc_repository); | gateway, active_repository, virtual_repository, plc_repository); | ||||
| @@ -246,9 +252,13 @@ void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect() | |||||
| ActiveRegisterRepository active_repository(virtual_repository); | ActiveRegisterRepository active_repository(virtual_repository); | ||||
| OfflineSimulationService simulation_service(virtual_repository); | OfflineSimulationService simulation_service(virtual_repository); | ||||
| OnlineLogicMonitorService online_monitor_service(plc_repository); | OnlineLogicMonitorService online_monitor_service(plc_repository); | ||||
| LogicEditorService logic_editor_service(project_service); | |||||
| FakePlcGateway gateway; | FakePlcGateway gateway; | ||||
| RuntimeModeService service( | RuntimeModeService service( | ||||
| project_service, simulation_service, online_monitor_service); | |||||
| project_service, | |||||
| logic_editor_service, | |||||
| simulation_service, | |||||
| online_monitor_service); | |||||
| service.configurePlc( | service.configurePlc( | ||||
| gateway, active_repository, virtual_repository, plc_repository); | gateway, active_repository, virtual_repository, plc_repository); | ||||
| @@ -10,6 +10,7 @@ SOURCES += \ | |||||
| plc_runtime_tests.cpp \ | plc_runtime_tests.cpp \ | ||||
| $$DOMAIN_ALL_SOURCES \ | $$DOMAIN_ALL_SOURCES \ | ||||
| $$SERVICE_PROJECT_SOURCES \ | $$SERVICE_PROJECT_SOURCES \ | ||||
| $$SERVICE_LOGIC_SOURCES \ | |||||
| $$SERVICE_OFFLINE_SOURCES \ | $$SERVICE_OFFLINE_SOURCES \ | ||||
| $$SERVICE_RUNTIME_SOURCES \ | $$SERVICE_RUNTIME_SOURCES \ | ||||
| $$INFRASTRUCTURE_PLC_SOURCES | $$INFRASTRUCTURE_PLC_SOURCES | ||||
| @@ -17,6 +18,7 @@ SOURCES += \ | |||||
| HEADERS += \ | HEADERS += \ | ||||
| $$DOMAIN_ALL_HEADERS \ | $$DOMAIN_ALL_HEADERS \ | ||||
| $$SERVICE_PROJECT_HEADERS \ | $$SERVICE_PROJECT_HEADERS \ | ||||
| $$SERVICE_LOGIC_HEADERS \ | |||||
| $$SERVICE_OFFLINE_HEADERS \ | $$SERVICE_OFFLINE_HEADERS \ | ||||
| $$SERVICE_RUNTIME_HEADERS \ | $$SERVICE_RUNTIME_HEADERS \ | ||||
| $$INFRASTRUCTURE_PLC_HEADERS \ | $$INFRASTRUCTURE_PLC_HEADERS \ | ||||
| @@ -1,4 +1,5 @@ | |||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "services/logic_editor_service.h" | |||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| @@ -100,17 +101,17 @@ private: | |||||
| using TestSupport::require; | using TestSupport::require; | ||||
| ConditionExpression conditionWithOutputWire( | |||||
| LogicNode node, | |||||
| const std::string &wire_id) | |||||
| void setConditionPath(LadderRung *rung, LogicNode node) | |||||
| { | { | ||||
| ConditionExpression series; | |||||
| series.id = wire_id + "-series"; | |||||
| series.kind = ConditionExpressionKind::Series; | |||||
| series.children = { | |||||
| ConditionExpression::fromNode(std::move(node)), | |||||
| ConditionExpression::fromWire(wire_id, 9)}; | |||||
| return series; | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| rung->cells.push_back({ | |||||
| rung->id + "-cell-" + std::to_string(column), | |||||
| column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, | |||||
| column == 0 ? std::optional<LogicNode>{node} : std::nullopt}); | |||||
| } | |||||
| } | } | ||||
| void testModeTransitions() | void testModeTransitions() | ||||
| @@ -123,9 +124,13 @@ void testModeTransitions() | |||||
| ActiveRegisterRepository active_repository(virtual_repository); | ActiveRegisterRepository active_repository(virtual_repository); | ||||
| OfflineSimulationService simulation_service(virtual_repository); | OfflineSimulationService simulation_service(virtual_repository); | ||||
| OnlineLogicMonitorService online_monitor_service(plc_repository); | OnlineLogicMonitorService online_monitor_service(plc_repository); | ||||
| LogicEditorService logic_editor_service(project_service); | |||||
| ReadyPlcGateway gateway; | ReadyPlcGateway gateway; | ||||
| RuntimeModeService service( | RuntimeModeService service( | ||||
| project_service, simulation_service, online_monitor_service); | |||||
| project_service, | |||||
| logic_editor_service, | |||||
| simulation_service, | |||||
| online_monitor_service); | |||||
| service.configurePlc( | service.configurePlc( | ||||
| gateway, active_repository, virtual_repository, plc_repository); | gateway, active_repository, virtual_repository, plc_repository); | ||||
| @@ -152,8 +157,7 @@ void testModeTransitions() | |||||
| LadderRung edge_rung; | LadderRung edge_rung; | ||||
| edge_rung.id = "poll-edge-rung"; | edge_rung.id = "poll-edge-rung"; | ||||
| edge_rung.name = "Poll edge"; | edge_rung.name = "Poll edge"; | ||||
| edge_rung.condition = conditionWithOutputWire( | |||||
| edge, "poll-edge-output-wire"); | |||||
| setConditionPath(&edge_rung, edge); | |||||
| edge_rung.output = edge_coil; | edge_rung.output = edge_coil; | ||||
| logic.rungs.push_back(edge_rung); | logic.rungs.push_back(edge_rung); | ||||
| LogicNode comparison; | LogicNode comparison; | ||||
| @@ -167,8 +171,7 @@ void testModeTransitions() | |||||
| LadderRung comparison_rung; | LadderRung comparison_rung; | ||||
| comparison_rung.id = "poll-comparison-rung"; | comparison_rung.id = "poll-comparison-rung"; | ||||
| comparison_rung.name = "Poll comparison"; | comparison_rung.name = "Poll comparison"; | ||||
| comparison_rung.condition = conditionWithOutputWire( | |||||
| comparison, "poll-comparison-output-wire"); | |||||
| setConditionPath(&comparison_rung, comparison); | |||||
| comparison_rung.output = comparison_coil; | comparison_rung.output = comparison_coil; | ||||
| logic.rungs.push_back(comparison_rung); | logic.rungs.push_back(comparison_rung); | ||||
| LogicNode move_input; | LogicNode move_input; | ||||
| @@ -186,8 +189,7 @@ void testModeTransitions() | |||||
| LadderRung move_rung; | LadderRung move_rung; | ||||
| move_rung.id = "poll-move-rung"; | move_rung.id = "poll-move-rung"; | ||||
| move_rung.name = "Poll MOVE"; | move_rung.name = "Poll MOVE"; | ||||
| move_rung.condition = conditionWithOutputWire( | |||||
| move_input, "poll-move-output-wire"); | |||||
| setConditionPath(&move_rung, move_input); | |||||
| move_rung.output = move_output; | move_rung.output = move_output; | ||||
| logic.rungs.push_back(move_rung); | logic.rungs.push_back(move_rung); | ||||
| LogicNode add_input; | LogicNode add_input; | ||||
| @@ -210,8 +212,7 @@ void testModeTransitions() | |||||
| LadderRung add_rung; | LadderRung add_rung; | ||||
| add_rung.id = "poll-add-rung"; | add_rung.id = "poll-add-rung"; | ||||
| add_rung.name = "Poll ADD"; | add_rung.name = "Poll ADD"; | ||||
| add_rung.condition = conditionWithOutputWire( | |||||
| add_input, "poll-add-output-wire"); | |||||
| setConditionPath(&add_rung, add_input); | |||||
| add_rung.output = add_output; | add_rung.output = add_output; | ||||
| logic.rungs.push_back(add_rung); | logic.rungs.push_back(add_rung); | ||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||
| @@ -276,6 +277,100 @@ void testModeTransitions() | |||||
| "a completed PLC poll cycle must trigger one new local trace scan"); | "a completed PLC poll cycle must trigger one new local trace scan"); | ||||
| } | } | ||||
| void testDisconnectedOutputBlocksOfflineAndOnlineRuntime() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| VirtualRegisterRepository virtual_repository; | |||||
| VirtualRegisterRepository plc_repository; | |||||
| ActiveRegisterRepository active_repository(virtual_repository); | |||||
| OfflineSimulationService simulation_service(virtual_repository); | |||||
| OnlineLogicMonitorService online_monitor_service(plc_repository); | |||||
| LogicEditorService logic_editor_service(project_service); | |||||
| ReadyPlcGateway gateway; | |||||
| RuntimeModeService service( | |||||
| project_service, | |||||
| logic_editor_service, | |||||
| simulation_service, | |||||
| online_monitor_service); | |||||
| service.configurePlc( | |||||
| gateway, active_repository, virtual_repository, plc_repository); | |||||
| ControlLogic logic; | |||||
| logic.id = "broken-logic"; | |||||
| logic.name = "断路逻辑"; | |||||
| LadderRung rung; | |||||
| rung.id = "broken-rung"; | |||||
| rung.name = "行 1"; | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| rung.cells.push_back({ | |||||
| "broken-cell-" + std::to_string(column), | |||||
| LadderCellKind::Gap, | |||||
| std::nullopt}); | |||||
| } | |||||
| rung.output = LogicNode{ | |||||
| "broken-output", | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal}, | |||||
| true}; | |||||
| logic.rungs.push_back(rung); | |||||
| LadderRung unused; | |||||
| unused.id = "unused-rung"; | |||||
| unused.name = "行 2"; | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| unused.cells.push_back({ | |||||
| "unused-cell-" + std::to_string(column), | |||||
| column >= 3 && column <= 5 | |||||
| ? LadderCellKind::Wire : LadderCellKind::Gap, | |||||
| std::nullopt}); | |||||
| } | |||||
| logic.rungs.push_back(unused); | |||||
| project_service.editProject().controlLogics.push_back(logic); | |||||
| logic_editor_service.clearHistory(); | |||||
| const ModeTransitionResult offline = service.enterOfflineRunning(); | |||||
| require( | |||||
| !offline.succeeded | |||||
| && offline.error == ModeTransitionError::ProjectNotReady | |||||
| && offline.detail.find("断路逻辑") != std::string::npos | |||||
| && offline.detail.find("第 11 列") != std::string::npos | |||||
| && offline.detail.find("第 1 列") != std::string::npos | |||||
| && service.mode() == ApplicationMode::Editing | |||||
| && simulation_service.state() == SimulationState::Stopped | |||||
| && service.lastSyntaxCheck().changed | |||||
| && service.lastSyntaxCheck().removedWireCells == 3U | |||||
| && logic_editor_service.findCell( | |||||
| "broken-logic", "unused-rung", 3)->kind | |||||
| == LadderCellKind::Gap | |||||
| && logic_editor_service.canUndo(), | |||||
| "runtime preflight must normalize unused lines before rejecting a broken output"); | |||||
| require(service.connectPlc( | |||||
| {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | |||||
| "the online connectivity check requires a ready PLC cache"); | |||||
| gateway.completeInitialRead(); | |||||
| const ModeTransitionResult online = service.enterOnlineRunning(); | |||||
| require( | |||||
| !online.succeeded | |||||
| && online.error == ModeTransitionError::ProjectNotReady | |||||
| && online.detail == offline.detail | |||||
| && service.mode() == ApplicationMode::Editing | |||||
| && online_monitor_service.state() | |||||
| == OnlineLogicMonitorState::Stopped | |||||
| && !service.lastSyntaxCheck().changed | |||||
| && logic_editor_service.undo().succeeded | |||||
| && logic_editor_service.findCell( | |||||
| "broken-logic", "unused-rung", 3)->kind | |||||
| == LadderCellKind::Wire, | |||||
| "the same disconnected output validation must block online runtime"); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main() | int main() | ||||
| @@ -284,6 +379,7 @@ int main() | |||||
| { | { | ||||
| // 运行模式只有这一组状态机边界测试 | // 运行模式只有这一组状态机边界测试 | ||||
| testModeTransitions(); | testModeTransitions(); | ||||
| testDisconnectedOutputBlocksOfflineAndOnlineRuntime(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -8,12 +8,14 @@ SOURCES += \ | |||||
| runtime_mode_service_tests.cpp \ | runtime_mode_service_tests.cpp \ | ||||
| $$DOMAIN_ALL_SOURCES \ | $$DOMAIN_ALL_SOURCES \ | ||||
| $$SERVICE_PROJECT_SOURCES \ | $$SERVICE_PROJECT_SOURCES \ | ||||
| $$SERVICE_LOGIC_SOURCES \ | |||||
| $$SERVICE_OFFLINE_SOURCES \ | $$SERVICE_OFFLINE_SOURCES \ | ||||
| $$SERVICE_RUNTIME_SOURCES | $$SERVICE_RUNTIME_SOURCES | ||||
| HEADERS += \ | HEADERS += \ | ||||
| $$DOMAIN_ALL_HEADERS \ | $$DOMAIN_ALL_HEADERS \ | ||||
| $$SERVICE_PROJECT_HEADERS \ | $$SERVICE_PROJECT_HEADERS \ | ||||
| $$SERVICE_LOGIC_HEADERS \ | |||||
| $$SERVICE_OFFLINE_HEADERS \ | $$SERVICE_OFFLINE_HEADERS \ | ||||
| $$SERVICE_RUNTIME_HEADERS \ | $$SERVICE_RUNTIME_HEADERS \ | ||||
| $$TEST_SUPPORT_HEADERS | $$TEST_SUPPORT_HEADERS | ||||
| @@ -17,9 +17,18 @@ | |||||
| #include <QApplication> | #include <QApplication> | ||||
| #include <QCoreApplication> | #include <QCoreApplication> | ||||
| #include <QEventLoop> | #include <QEventLoop> | ||||
| #include <QGraphicsLineItem> | |||||
| #include <QGraphicsScene> | |||||
| #include <QGraphicsSimpleTextItem> | |||||
| #include <QImage> | |||||
| #include <QKeyEvent> | |||||
| #include <QLabel> | #include <QLabel> | ||||
| #include <QLineEdit> | |||||
| #include <QMouseEvent> | |||||
| #include <QPainter> | |||||
| #include <QWidget> | #include <QWidget> | ||||
| #include <algorithm> | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| #include <string> | #include <string> | ||||
| @@ -38,7 +47,15 @@ ControlLogic makeAlwaysOnLogic() | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "always-on-rung"; | rung.id = "always-on-rung"; | ||||
| rung.name = "Always on"; | rung.name = "Always on"; | ||||
| rung.condition = ConditionExpression::fromWire("always-on-wire", 10); | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| rung.cells.push_back({ | |||||
| "always-on-cell-" + std::to_string(column), | |||||
| LadderCellKind::Wire, | |||||
| std::nullopt}); | |||||
| } | |||||
| LogicNode output; | LogicNode output; | ||||
| output.id = "always-on-output"; | output.id = "always-on-output"; | ||||
| @@ -56,11 +73,14 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||||
| VirtualRegisterRepository virtual_repository; | VirtualRegisterRepository virtual_repository; | ||||
| OfflineSimulationService simulation_service(virtual_repository); | OfflineSimulationService simulation_service(virtual_repository); | ||||
| OnlineLogicMonitorService online_monitor_service(virtual_repository); | OnlineLogicMonitorService online_monitor_service(virtual_repository); | ||||
| LogicEditorService logic_editor_service(project_service); | |||||
| RuntimeModeService runtime_mode_service( | RuntimeModeService runtime_mode_service( | ||||
| project_service, simulation_service, online_monitor_service); | |||||
| project_service, | |||||
| logic_editor_service, | |||||
| simulation_service, | |||||
| online_monitor_service); | |||||
| HmiEditorService hmi_editor_service(project_service); | HmiEditorService hmi_editor_service(project_service); | ||||
| HmiRuntimeService hmi_runtime_service(virtual_repository); | HmiRuntimeService hmi_runtime_service(virtual_repository); | ||||
| LogicEditorService logic_editor_service(project_service); | |||||
| HmiNavigationService hmi_navigation_service(project_service); | HmiNavigationService hmi_navigation_service(project_service); | ||||
| AlarmService alarm_service(project_service, virtual_repository); | AlarmService alarm_service(project_service, virtual_repository); | ||||
| RegisterMonitorService register_monitor_service(virtual_repository); | RegisterMonitorService register_monitor_service(virtual_repository); | ||||
| @@ -128,6 +148,27 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||||
| require(simulation_service.traceSnapshot() | require(simulation_service.traceSnapshot() | ||||
| .forLogic(logic.id).rungValues.at("always-on-rung"), | .forLogic(logic.id).rungValues.at("always-on-rung"), | ||||
| "the queued trace must contain an energized rung"); | "the queued trace must contain an energized rung"); | ||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| LogicEditorWidget *runtime_logic_view = controller.runtimeMonitorWidget() | |||||
| ->findChild<LogicEditorWidget *>(QStringLiteral("runtimeLogicView")); | |||||
| bool found_active_runtime_output = false; | |||||
| require(runtime_logic_view != nullptr, | |||||
| "runtime monitor must own the ladder trace view"); | |||||
| for (QGraphicsItem *item : runtime_logic_view->scene()->items()) | |||||
| { | |||||
| if (item->data(0).toString() == QStringLiteral("output") | |||||
| && item->data(1).toString() | |||||
| == QStringLiteral("always-on-rung")) | |||||
| { | |||||
| found_active_runtime_output = item->data(3).toBool() | |||||
| && item->data(4).toBool(); | |||||
| } | |||||
| } | |||||
| require(found_active_runtime_output, | |||||
| "the full executor snapshot must reach the runtime ladder exactly once"); | |||||
| require(simulation_service.executeOnce().succeeded, | |||||
| "a second scan must queue the stale-trace regression event"); | |||||
| require(runtime_mode_service.enterEditing().succeeded, | require(runtime_mode_service.enterEditing().succeeded, | ||||
| "offline simulation must return to editing before queued delivery"); | "offline simulation must return to editing before queued delivery"); | ||||
| @@ -142,6 +183,671 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||||
| "a queued offline scan must not restore the trace after returning to editing"); | "a queued offline scan must not restore the trace after returning to editing"); | ||||
| } | } | ||||
| void testLogicEditorGridSelectionAndDeletion() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService logic_editor_service(project_service); | |||||
| const std::string logic_id = logic_editor_service.ensureDefaultLogic().id; | |||||
| LogicEditorWidget widget(logic_editor_service); | |||||
| widget.setLogicId(logic_id); | |||||
| require(widget.scene()->items().isEmpty(), | |||||
| "an empty logic must not draw standalone power rails"); | |||||
| require((widget.alignment() & Qt::AlignLeft) != 0 | |||||
| && (widget.alignment() & Qt::AlignTop) != 0, | |||||
| "the ladder canvas must start at its top-left origin"); | |||||
| const std::string rung_id = logic_editor_service.addRung(logic_id).id; | |||||
| require(!rung_id.empty(), "the grid regression fixture must create a row"); | |||||
| require(logic_editor_service.setHorizontalWireRange( | |||||
| logic_id, rung_id, 4, 4, true).succeeded, | |||||
| "the grid regression fixture must draw one horizontal cell"); | |||||
| widget.reloadLogic(); | |||||
| widget.resize(1200, 400); | |||||
| widget.show(); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| constexpr qreal left_bus = 60.0; | |||||
| constexpr qreal cell_width = 96.0; | |||||
| constexpr qreal output_width = 224.0; | |||||
| constexpr qreal first_grid_top = 46.0; | |||||
| constexpr qreal row_height = 78.0; | |||||
| const auto clickScene = [&widget](const QPointF &position, | |||||
| Qt::KeyboardModifiers modifiers = Qt::NoModifier) | |||||
| { | |||||
| const QPoint point = widget.mapFromScene(position); | |||||
| QMouseEvent press( | |||||
| QEvent::MouseButtonPress, | |||||
| QPointF(point), | |||||
| Qt::LeftButton, | |||||
| Qt::LeftButton, | |||||
| modifiers); | |||||
| QApplication::sendEvent(widget.viewport(), &press); | |||||
| QMouseEvent release( | |||||
| QEvent::MouseButtonRelease, | |||||
| QPointF(point), | |||||
| Qt::LeftButton, | |||||
| Qt::NoButton, | |||||
| modifiers); | |||||
| QApplication::sendEvent(widget.viewport(), &release); | |||||
| }; | |||||
| const auto dragScene = [&widget]( | |||||
| const QPointF &from, | |||||
| const QPointF &to, | |||||
| Qt::KeyboardModifiers modifiers = Qt::NoModifier) | |||||
| { | |||||
| const QPoint from_point = widget.mapFromScene(from); | |||||
| const QPoint to_point = widget.mapFromScene(to); | |||||
| QMouseEvent press( | |||||
| QEvent::MouseButtonPress, | |||||
| QPointF(from_point), | |||||
| Qt::LeftButton, | |||||
| Qt::LeftButton, | |||||
| modifiers); | |||||
| QApplication::sendEvent(widget.viewport(), &press); | |||||
| QMouseEvent move( | |||||
| QEvent::MouseMove, | |||||
| QPointF(to_point), | |||||
| Qt::NoButton, | |||||
| Qt::LeftButton, | |||||
| modifiers); | |||||
| QApplication::sendEvent(widget.viewport(), &move); | |||||
| QMouseEvent release( | |||||
| QEvent::MouseButtonRelease, | |||||
| QPointF(to_point), | |||||
| Qt::LeftButton, | |||||
| Qt::NoButton, | |||||
| modifiers); | |||||
| QApplication::sendEvent(widget.viewport(), &release); | |||||
| }; | |||||
| clickScene(QPointF( | |||||
| left_bus + 4.0 * cell_width + cell_width / 2.0, | |||||
| first_grid_top + row_height / 2.0)); | |||||
| require(widget.selectedRungId() == rung_id, | |||||
| "clicking a grid cell must select its row"); | |||||
| QString delete_error; | |||||
| QObject::connect( | |||||
| &widget, | |||||
| &LogicEditorWidget::editorError, | |||||
| [&delete_error](const QString &message) { delete_error = message; }); | |||||
| const LogicEditorResult deleted = widget.deleteSelected(); | |||||
| require(deleted.succeeded, | |||||
| "Delete on a selected horizontal cell must succeed: " | |||||
| + delete_error.toStdString()); | |||||
| const LadderRung *rung = logic_editor_service.findRung(logic_id, rung_id); | |||||
| require(rung != nullptr && rung->cells.size() == 10U | |||||
| && rung->cells[4].kind == LadderCellKind::Gap, | |||||
| "Delete on one horizontal cell must preserve the row and clear only that cell"); | |||||
| widget.clearSelection(); | |||||
| require(widget.selectedRungId().empty(), | |||||
| "clearing the selection must not expose the first row as selected"); | |||||
| require(!widget.addHorizontalWire().succeeded, | |||||
| "the horizontal-wire command must require an explicitly selected cell"); | |||||
| require(logic_editor_service.setHorizontalWireRange( | |||||
| logic_id, rung_id, 3, 3, true).succeeded, | |||||
| "fixture must restore a wire for precise hit testing"); | |||||
| widget.reloadLogic(); | |||||
| clickScene(QPointF( | |||||
| left_bus + 3.0 * cell_width, | |||||
| first_grid_top + row_height / 2.0)); | |||||
| require(!widget.deleteSelected().succeeded, | |||||
| "Delete on a column boundary must not delete an adjacent cell"); | |||||
| require(logic_editor_service.findRung(logic_id, rung_id)->cells[3].kind | |||||
| == LadderCellKind::Wire, | |||||
| "a boundary selection must preserve the adjacent horizontal wire"); | |||||
| clickScene(QPointF( | |||||
| left_bus + 10.0 * cell_width + output_width / 2.0, | |||||
| first_grid_top + row_height / 2.0)); | |||||
| require(!widget.deleteSelected().succeeded | |||||
| && logic_editor_service.findLogic(logic_id)->rungs.size() == 1U, | |||||
| "Delete on an empty output slot must never delete the whole row"); | |||||
| clickScene(QPointF( | |||||
| left_bus + 2.0 * cell_width + cell_width / 2.0, | |||||
| first_grid_top + row_height / 2.0)); | |||||
| require(widget.addHorizontalWire().succeeded | |||||
| && logic_editor_service.findRung(logic_id, rung_id)->cells[2].kind | |||||
| == LadderCellKind::Wire, | |||||
| "the horizontal-wire command must act on an explicitly selected cell"); | |||||
| const LogicEditorResult first_node = logic_editor_service.setConditionAtColumn( | |||||
| logic_id, | |||||
| rung_id, | |||||
| 0, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | |||||
| ContactMode::NormallyOpen}, | |||||
| true); | |||||
| const LogicEditorResult second_node = logic_editor_service.setConditionAtColumn( | |||||
| logic_id, | |||||
| rung_id, | |||||
| 1, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| ContactMode::NormallyOpen}, | |||||
| true); | |||||
| require(first_node.succeeded && second_node.succeeded, | |||||
| "fixture must create adjacent conditions for multi-selection"); | |||||
| widget.reloadLogic(); | |||||
| dragScene( | |||||
| QPointF(left_bus + 5.0, first_grid_top + 5.0), | |||||
| QPointF( | |||||
| left_bus + 2.0 * cell_width - 5.0, | |||||
| first_grid_top + row_height - 5.0)); | |||||
| require(widget.selectedNodeIds().size() == 2U, | |||||
| "mouse drag must select multiple conditions in the same row"); | |||||
| widget.clearSelection(); | |||||
| clickScene(QPointF( | |||||
| left_bus + cell_width / 2.0, | |||||
| first_grid_top + row_height / 2.0)); | |||||
| dragScene( | |||||
| QPointF(left_bus + cell_width + 5.0, first_grid_top + 5.0), | |||||
| QPointF( | |||||
| left_bus + 2.0 * cell_width - 5.0, | |||||
| first_grid_top + row_height - 5.0), | |||||
| Qt::ControlModifier); | |||||
| require(widget.selectedNodeIds().size() == 2U, | |||||
| "Ctrl-drag must append objects to the existing selection"); | |||||
| require(widget.addParallelBranch(ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 2}, | |||||
| ContactMode::NormallyOpen}).succeeded | |||||
| && logic_editor_service.findLogic(logic_id)->rungs.size() == 2U, | |||||
| "parallel insertion must use the explicitly selected condition range"); | |||||
| const LogicEditorResult output = logic_editor_service.setOutput( | |||||
| logic_id, | |||||
| rung_id, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 3}, CoilMode::Normal}, | |||||
| true); | |||||
| require(output.succeeded, | |||||
| "syntax-location fixture must create an output instruction"); | |||||
| widget.reloadLogic(); | |||||
| widget.focusSyntaxLocation( | |||||
| rung_id, ProjectLimits::kMaximumLadderColumns); | |||||
| require( | |||||
| widget.selectedRungId() == rung_id | |||||
| && widget.selectedNodeId() == output.id, | |||||
| "a syntax error at column 11 must focus the output slot"); | |||||
| widget.focusSyntaxLocation(rung_id, 1); | |||||
| require( | |||||
| widget.selectedRungId() == rung_id | |||||
| && widget.selectedNodeId() == first_node.id, | |||||
| "a syntax error in the condition area must focus its one-based column"); | |||||
| } | |||||
| void testLadderLayoutAndDragDeletion() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService editor(project_service); | |||||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||||
| const std::string upper = editor.addRung(logic_id).id; | |||||
| const std::string lower = editor.addRung(logic_id).id; | |||||
| require( | |||||
| editor.setConditionAtColumn( | |||||
| logic_id, | |||||
| upper, | |||||
| 0, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, | |||||
| ContactMode::NormallyClosed}, | |||||
| true).succeeded | |||||
| && editor.setHorizontalWireRange( | |||||
| logic_id, upper, 1, 1, true).succeeded | |||||
| && editor.setOutput( | |||||
| logic_id, | |||||
| upper, | |||||
| MoveNodeConfig{ | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 10}, | |||||
| 0}, | |||||
| RegisterAddress{RegisterArea::D, 20}}, | |||||
| true).succeeded | |||||
| && editor.setHorizontalWireRange( | |||||
| logic_id, lower, 0, 1, true).succeeded | |||||
| && editor.setVerticalConnection( | |||||
| logic_id, upper, lower, 2, true).succeeded | |||||
| && editor.updateRungComment( | |||||
| logic_id, upper, "主网络注释").succeeded | |||||
| && editor.updateRungComment( | |||||
| logic_id, lower, "支路注释不应重复").succeeded, | |||||
| "layout fixture must create a connected two-row network"); | |||||
| editor.clearHistory(); | |||||
| LogicEditorWidget widget(editor); | |||||
| widget.setLogicId(logic_id); | |||||
| widget.resize(1320, 360); | |||||
| widget.show(); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| bool found_head_comment = false; | |||||
| bool found_branch_comment = false; | |||||
| for (QGraphicsItem *item : widget.scene()->items()) | |||||
| { | |||||
| auto *text = dynamic_cast<QGraphicsSimpleTextItem *>(item); | |||||
| if (text == nullptr) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| found_head_comment = found_head_comment | |||||
| || text->text() == QStringLiteral("主网络注释"); | |||||
| found_branch_comment = found_branch_comment | |||||
| || text->text() == QStringLiteral("支路注释不应重复"); | |||||
| } | |||||
| require(found_head_comment && !found_branch_comment, | |||||
| "only the network-head comment must be rendered"); | |||||
| constexpr qreal left_bus = 60.0; | |||||
| constexpr qreal cell_width = 96.0; | |||||
| constexpr qreal right_bus = left_bus + 10.0 * cell_width + 224.0; | |||||
| constexpr qreal first_grid_top = 46.0; | |||||
| constexpr qreal second_grid_bottom = first_grid_top + 2.0 * 78.0; | |||||
| QImage grid_image(1320, 360, QImage::Format_ARGB32_Premultiplied); | |||||
| grid_image.fill(Qt::transparent); | |||||
| { | |||||
| QPainter painter(&grid_image); | |||||
| widget.scene()->render( | |||||
| &painter, | |||||
| QRectF(0.0, 0.0, 1320.0, 360.0), | |||||
| QRectF(0.0, 0.0, 1320.0, 360.0), | |||||
| Qt::IgnoreAspectRatio); | |||||
| } | |||||
| const auto background_at = [&grid_image, first_grid_top](qreal x) | |||||
| { | |||||
| return grid_image.pixelColor( | |||||
| qRound(x), qRound(first_grid_top + 68.0)); | |||||
| }; | |||||
| const QColor node_background = background_at( | |||||
| left_bus + cell_width - 10.0); | |||||
| const QColor wire_background = background_at( | |||||
| left_bus + 2.0 * cell_width - 10.0); | |||||
| const QColor gap_background = background_at( | |||||
| left_bus + 3.0 * cell_width - 10.0); | |||||
| const QColor output_background = background_at(right_bus - 10.0); | |||||
| require( | |||||
| node_background == QColor(QStringLiteral("#ffffff")) | |||||
| && node_background == wire_background | |||||
| && wire_background == gap_background | |||||
| && gap_background == output_background, | |||||
| "node, wire, gap, and output cells must share one grid background"); | |||||
| bool found_left_rail = false; | |||||
| bool found_right_rail = false; | |||||
| for (QGraphicsItem *item : widget.scene()->items()) | |||||
| { | |||||
| auto *line_item = dynamic_cast<QGraphicsLineItem *>(item); | |||||
| if (line_item == nullptr || line_item->data(0).isValid()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const QLineF line = line_item->line(); | |||||
| const bool exact_span = qFuzzyCompare( | |||||
| line.y1() + 1.0, first_grid_top + 1.0) | |||||
| && qFuzzyCompare( | |||||
| line.y2() + 1.0, second_grid_bottom + 1.0); | |||||
| found_left_rail = found_left_rail | |||||
| || (exact_span && qFuzzyCompare( | |||||
| line.x1() + 1.0, left_bus + 1.0)); | |||||
| found_right_rail = found_right_rail | |||||
| || (exact_span && qFuzzyCompare( | |||||
| line.x1() + 1.0, right_bus + 1.0)); | |||||
| } | |||||
| require(found_left_rail && found_right_rail, | |||||
| "both rails must share the exact first-to-last row span"); | |||||
| const QPoint from = widget.mapFromScene( | |||||
| QPointF(left_bus + 4.0, first_grid_top + 4.0)); | |||||
| const QPoint to = widget.mapFromScene( | |||||
| QPointF(right_bus - 4.0, second_grid_bottom - 4.0)); | |||||
| QMouseEvent press( | |||||
| QEvent::MouseButtonPress, | |||||
| QPointF(from), | |||||
| Qt::LeftButton, | |||||
| Qt::LeftButton, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(widget.viewport(), &press); | |||||
| QMouseEvent move( | |||||
| QEvent::MouseMove, | |||||
| QPointF(to), | |||||
| Qt::NoButton, | |||||
| Qt::LeftButton, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(widget.viewport(), &move); | |||||
| QMouseEvent release( | |||||
| QEvent::MouseButtonRelease, | |||||
| QPointF(to), | |||||
| Qt::LeftButton, | |||||
| Qt::NoButton, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(widget.viewport(), &release); | |||||
| require(widget.selectedNodeIds().size() == 2U, | |||||
| "drag selection must include the condition and output instruction"); | |||||
| const QList<QGraphicsItem *> selected_scene_items = widget.scene()->items(); | |||||
| require( | |||||
| std::any_of( | |||||
| selected_scene_items.cbegin(), | |||||
| selected_scene_items.cend(), | |||||
| [](QGraphicsItem *item) { return item->zValue() == 100.0; }), | |||||
| "selected objects must be painted by the highest selection layer"); | |||||
| require(widget.deleteSelected().succeeded, | |||||
| "Delete must submit the complete drag selection once"); | |||||
| const LadderRung *upper_rung = editor.findRung(logic_id, upper); | |||||
| const LadderRung *lower_rung = editor.findRung(logic_id, lower); | |||||
| require( | |||||
| upper_rung->cells[0].kind == LadderCellKind::Gap | |||||
| && upper_rung->cells[1].kind == LadderCellKind::Gap | |||||
| && !upper_rung->output.has_value() | |||||
| && lower_rung->cells[0].kind == LadderCellKind::Gap | |||||
| && lower_rung->cells[1].kind == LadderCellKind::Gap | |||||
| && editor.findLogic(logic_id)->verticalConnections.empty(), | |||||
| "drag deletion must clear cells, output, and vertical connection together"); | |||||
| require(editor.undo().succeeded, | |||||
| "one undo must restore the complete drag deletion"); | |||||
| upper_rung = editor.findRung(logic_id, upper); | |||||
| require( | |||||
| upper_rung->cells[0].kind == LadderCellKind::Node | |||||
| && upper_rung->cells[1].kind == LadderCellKind::Wire | |||||
| && upper_rung->output.has_value() | |||||
| && editor.findLogic(logic_id)->verticalConnections.size() == 1U, | |||||
| "one undo must restore every object removed by the drag selection"); | |||||
| } | |||||
| void testCursorAdvanceAndInlineCommandInput() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService editor(project_service); | |||||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||||
| LogicEditorWidget widget(editor); | |||||
| widget.setLogicId(logic_id); | |||||
| widget.resize(1320, 440); | |||||
| widget.show(); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| for (int column = 0; | |||||
| column < ProjectLimits::kMaximumConditionColumns; | |||||
| ++column) | |||||
| { | |||||
| require(widget.addHorizontalWire().succeeded, | |||||
| "repeated toolbar wire input must advance through all ten cells"); | |||||
| } | |||||
| const ControlLogic *logic = editor.findLogic(logic_id); | |||||
| require(logic != nullptr && logic->rungs.size() == 1U, | |||||
| "the first wire on empty logic must atomically create one row"); | |||||
| for (const LadderCell &cell : logic->rungs.front().cells) | |||||
| { | |||||
| require(cell.kind == LadderCellKind::Wire, | |||||
| "ten repeated wire actions must fill ten distinct cells"); | |||||
| } | |||||
| require(widget.setOutput( | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal}, | |||||
| true).succeeded, | |||||
| "the output action must succeed after the tenth cell"); | |||||
| logic = editor.findLogic(logic_id); | |||||
| require(logic->rungs.size() == 2U | |||||
| && logic->rungs.front().output.has_value(), | |||||
| "the output action must append the next empty row atomically"); | |||||
| require(widget.addHorizontalWire().succeeded | |||||
| && editor.findLogic(logic_id)->rungs[1].cells[0].kind | |||||
| == LadderCellKind::Wire, | |||||
| "the toolbar cursor must continue at the appended row first cell"); | |||||
| constexpr qreal left_bus = 60.0; | |||||
| constexpr qreal cell_width = 96.0; | |||||
| constexpr qreal output_width = 224.0; | |||||
| constexpr qreal second_row_center = 197.0; | |||||
| const auto double_click = [&widget](const QPointF &scene_point) | |||||
| { | |||||
| const QPoint point = widget.mapFromScene(scene_point); | |||||
| QMouseEvent event( | |||||
| QEvent::MouseButtonDblClick, | |||||
| QPointF(point), | |||||
| Qt::LeftButton, | |||||
| Qt::LeftButton, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(widget.viewport(), &event); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| }; | |||||
| const auto press_enter = [](QLineEdit *input) | |||||
| { | |||||
| QKeyEvent event( | |||||
| QEvent::KeyPress, | |||||
| Qt::Key_Return, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(input, &event); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| }; | |||||
| double_click(QPointF( | |||||
| left_bus + 1.5 * cell_width, | |||||
| second_row_center)); | |||||
| QLineEdit *input = widget.findChild<QLineEdit *>( | |||||
| QStringLiteral("logicCommandInput")); | |||||
| require(input != nullptr && input->isVisible() | |||||
| && input->completer() != nullptr, | |||||
| "double-clicking a cell must open the inline command editor with completion"); | |||||
| const int first_input_left = input->geometry().left(); | |||||
| input->setText(QStringLiteral("LD M4")); | |||||
| press_enter(input); | |||||
| const LadderRung &second = editor.findLogic(logic_id)->rungs[1]; | |||||
| require(second.cells[1].node.has_value() | |||||
| && input->isVisible() | |||||
| && input->geometry().left() > first_input_left, | |||||
| "a committed inline condition must move the editor one cell right"); | |||||
| double_click(QPointF( | |||||
| left_bus + ProjectLimits::kMaximumConditionColumns * cell_width | |||||
| + output_width / 2.0, | |||||
| second_row_center)); | |||||
| input->setText(QStringLiteral("OUT M61")); | |||||
| press_enter(input); | |||||
| logic = editor.findLogic(logic_id); | |||||
| require(logic->rungs.size() == 3U | |||||
| && logic->rungs[1].output.has_value() | |||||
| && input->isVisible(), | |||||
| "an inline output must append a row and keep continuous input active"); | |||||
| } | |||||
| void testSegmentLevelTraceProjection() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService editor(project_service); | |||||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||||
| const std::string upper = editor.addRung(logic_id).id; | |||||
| const std::string lower = editor.addRung(logic_id).id; | |||||
| const LogicEditorResult condition = editor.setConditionAtColumn( | |||||
| logic_id, | |||||
| upper, | |||||
| 0, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | |||||
| ContactMode::NormallyOpen}, | |||||
| true); | |||||
| const LogicEditorResult output = editor.setOutput( | |||||
| logic_id, | |||||
| upper, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, | |||||
| true); | |||||
| require(condition.succeeded && output.succeeded | |||||
| && editor.setVerticalConnection( | |||||
| logic_id, upper, lower, 0, true).succeeded, | |||||
| "the segment trace fixture must create a contact, output and vertical edge"); | |||||
| const ControlLogic *logic = editor.findLogic(logic_id); | |||||
| const std::string cell_id = logic->rungs.front().cells.front().id; | |||||
| const std::string vertical_id = logic->verticalConnections.front().id; | |||||
| LogicTraceSnapshot trace; | |||||
| LogicTraceValues &values = trace.logicValues[logic_id]; | |||||
| values.cellInputPowerValues[cell_id] = true; | |||||
| values.cellPowerValues[cell_id] = false; | |||||
| values.rungValues[upper] = false; | |||||
| values.nodeValues[output.id] = false; | |||||
| LogicEditorWidget widget(editor); | |||||
| widget.setLogicId(logic_id); | |||||
| widget.resize(1320, 360); | |||||
| widget.show(); | |||||
| widget.setRuntimeTrace(trace); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| bool found_split_contact = false; | |||||
| bool found_inactive_output = false; | |||||
| bool found_inactive_vertical = false; | |||||
| for (QGraphicsItem *item : widget.scene()->items()) | |||||
| { | |||||
| const QString type = item->data(0).toString(); | |||||
| if (type == QStringLiteral("cell") | |||||
| && item->data(1).toString().toStdString() == upper | |||||
| && item->data(2).toInt() == 0) | |||||
| { | |||||
| found_split_contact = item->data(6).toBool() | |||||
| && !item->data(7).toBool(); | |||||
| } | |||||
| else if (type == QStringLiteral("output") | |||||
| && item->data(1).toString().toStdString() == upper) | |||||
| { | |||||
| found_inactive_output = !item->data(3).toBool() | |||||
| && !item->data(4).toBool(); | |||||
| } | |||||
| else if (type == QStringLiteral("vertical") | |||||
| && item->data(1).toString().toStdString() == vertical_id) | |||||
| { | |||||
| found_inactive_vertical = !item->data(5).toBool(); | |||||
| } | |||||
| } | |||||
| require(found_split_contact && found_inactive_output | |||||
| && found_inactive_vertical, | |||||
| "trace projection must keep left/right contact power and inactive verticals separate"); | |||||
| values.cellPowerValues[cell_id] = true; | |||||
| values.rungValues[upper] = true; | |||||
| values.nodeValues[output.id] = true; | |||||
| values.verticalConnectionValues[vertical_id] = true; | |||||
| widget.setRuntimeTrace(trace); | |||||
| bool found_active_output = false; | |||||
| bool found_active_vertical = false; | |||||
| for (QGraphicsItem *item : widget.scene()->items()) | |||||
| { | |||||
| const QString type = item->data(0).toString(); | |||||
| if (type == QStringLiteral("output") | |||||
| && item->data(1).toString().toStdString() == upper) | |||||
| { | |||||
| found_active_output = item->data(3).toBool() | |||||
| && item->data(4).toBool(); | |||||
| } | |||||
| else if (type == QStringLiteral("vertical") | |||||
| && item->data(1).toString().toStdString() == vertical_id) | |||||
| { | |||||
| found_active_vertical = item->data(5).toBool(); | |||||
| } | |||||
| } | |||||
| require(found_active_output && found_active_vertical, | |||||
| "explicitly energized output and vertical segments must project as active"); | |||||
| } | |||||
| void testLogicClipboardUsesExplicitObjectAndRowSelection() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService editor(project_service); | |||||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||||
| const std::string source = editor.addRung(logic_id).id; | |||||
| const std::string target = editor.addRung(logic_id).id; | |||||
| require( | |||||
| editor.setHorizontalWireRange( | |||||
| logic_id, source, 0, 0, true).succeeded, | |||||
| "clipboard fixture must create one source wire"); | |||||
| editor.clearHistory(); | |||||
| LogicEditorWidget widget(editor); | |||||
| widget.setLogicId(logic_id); | |||||
| widget.resize(1320, 360); | |||||
| widget.show(); | |||||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||||
| const auto find_item = [&widget]( | |||||
| const QString &type, | |||||
| const std::string &rung_id, | |||||
| int column) -> QGraphicsItem * | |||||
| { | |||||
| const QList<QGraphicsItem *> items = widget.scene()->items(); | |||||
| const auto found = std::find_if( | |||||
| items.cbegin(), items.cend(), | |||||
| [&type, &rung_id, column](QGraphicsItem *item) | |||||
| { | |||||
| return item->data(0).toString() == type | |||||
| && item->data(1).toString().toStdString() == rung_id | |||||
| && (column < 0 || item->data(2).toInt() == column); | |||||
| }); | |||||
| return found == items.cend() ? nullptr : *found; | |||||
| }; | |||||
| const auto click_item = [&widget](QGraphicsItem *item) | |||||
| { | |||||
| require(item != nullptr, "clipboard test target item must exist"); | |||||
| const QPoint point = widget.mapFromScene( | |||||
| item->sceneBoundingRect().center()); | |||||
| QMouseEvent press( | |||||
| QEvent::MouseButtonPress, | |||||
| QPointF(point), | |||||
| Qt::LeftButton, | |||||
| Qt::LeftButton, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(widget.viewport(), &press); | |||||
| QMouseEvent release( | |||||
| QEvent::MouseButtonRelease, | |||||
| QPointF(point), | |||||
| Qt::LeftButton, | |||||
| Qt::NoButton, | |||||
| Qt::NoModifier); | |||||
| QApplication::sendEvent(widget.viewport(), &release); | |||||
| }; | |||||
| click_item(find_item(QStringLiteral("cell"), source, 0)); | |||||
| const LogicClipboardCopyResult wire_copy = widget.copySelection(); | |||||
| require( | |||||
| widget.hasCopyableSelection() | |||||
| && wire_copy.copy.succeeded | |||||
| && wire_copy.fragment.mode == LogicClipboardMode::GridObjects | |||||
| && wire_copy.fragment.cells.size() == 1U | |||||
| && wire_copy.fragment.cells.front().kind == LadderCellKind::Wire | |||||
| && wire_copy.fragment.rows.empty(), | |||||
| "clicking one wire must copy one grid object instead of its whole row"); | |||||
| click_item(find_item(QStringLiteral("cell"), target, 0)); | |||||
| const LogicClipboardPasteResult pasted = widget.pasteClipboard( | |||||
| wire_copy.fragment); | |||||
| require( | |||||
| pasted.edit.succeeded | |||||
| && editor.findLogic(logic_id)->rungs.size() == 2U | |||||
| && editor.findCell(logic_id, target, 0)->kind | |||||
| == LadderCellKind::Wire, | |||||
| "widget paste must place one wire without creating or copying a row"); | |||||
| const LogicClipboardCopyResult pasted_selection = widget.copySelection(); | |||||
| require( | |||||
| pasted_selection.copy.succeeded | |||||
| && pasted_selection.fragment.cells.size() == 1U | |||||
| && pasted_selection.fragment.cells.front().kind | |||||
| == LadderCellKind::Wire, | |||||
| "a successful paste must select the newly pasted wire"); | |||||
| click_item(find_item(QStringLiteral("rowHeader"), source, -1)); | |||||
| const LogicClipboardCopyResult row_copy = widget.copySelection(); | |||||
| require( | |||||
| row_copy.copy.succeeded | |||||
| && row_copy.fragment.mode == LogicClipboardMode::WholeRows | |||||
| && row_copy.fragment.rows.size() == 1U, | |||||
| "only clicking the left row header may create a whole-row clipboard"); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main(int argc, char *argv[]) | int main(int argc, char *argv[]) | ||||
| @@ -151,6 +857,11 @@ int main(int argc, char *argv[]) | |||||
| try | try | ||||
| { | { | ||||
| testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); | testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); | ||||
| testLogicEditorGridSelectionAndDeletion(); | |||||
| testLadderLayoutAndDragDeletion(); | |||||
| testCursorAdvanceAndInlineCommandInput(); | |||||
| testSegmentLevelTraceProjection(); | |||||
| testLogicClipboardUsesExplicitObjectAndRowSelection(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||