| @@ -9,253 +9,172 @@ | |||
| #include <variant> | |||
| #include <vector> | |||
| // 普通触点的导通方式;常闭触点会对读取到的位值取反 | |||
| enum class ContactMode | |||
| { | |||
| NormallyOpen, // 常开:位值为 1 时触点导通 | |||
| NormallyClosed // 常闭:位值为 0 时触点导通 | |||
| NormallyOpen, | |||
| NormallyClosed | |||
| }; | |||
| // 线圈写入方式:普通写入、置位保持、复位清零 | |||
| enum class CoilMode | |||
| { | |||
| Normal, // 普通写入:直接写入当前逻辑结果 | |||
| Set, // 置位:逻辑结果为 1 时保持为 1 | |||
| Reset // 复位:逻辑结果为 1 时清零 | |||
| Normal, | |||
| Set, | |||
| Reset | |||
| }; | |||
| // 上升沿/下降沿触点的边沿方向 | |||
| enum class EdgeMode | |||
| { | |||
| Rising, // 上升沿:信号从 0 变成 1 | |||
| Falling // 下降沿:信号从 1 变成 0 | |||
| Rising, | |||
| Falling | |||
| }; | |||
| // D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 | |||
| enum class ComparisonOperator | |||
| { | |||
| Equal, // 等于 | |||
| NotEqual, // 不等于 | |||
| LessThan, // 小于 | |||
| LessThanOrEqual, // 小于或等于 | |||
| GreaterThan, // 大于 | |||
| GreaterThanOrEqual // 大于或等于 | |||
| Equal, | |||
| NotEqual, | |||
| LessThan, | |||
| LessThanOrEqual, | |||
| GreaterThan, | |||
| GreaterThanOrEqual | |||
| }; | |||
| // 字操作数可以来自常量,也可以来自 D 寄存器 | |||
| enum class WordOperandKind | |||
| { | |||
| Constant, // 使用固定数值 | |||
| Register // 使用 D 寄存器中的数值 | |||
| Constant, | |||
| Register | |||
| }; | |||
| // MOVE、ADD/SUB 共用的字操作数 | |||
| 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; | |||
| }; | |||
| // 普通 M 寄存器触点,地址 + 常开常闭 | |||
| struct ContactNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 | |||
| ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| }; | |||
| // 边沿触点,地址 + 上升 / 下降沿 | |||
| struct EdgeContactNodeConfig | |||
| { | |||
| // 用于检测 M 寄存器的上升沿或下降沿 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| EdgeMode mode = EdgeMode::Rising; | |||
| }; | |||
| // 线圈输出配置,决定如何写入 M 寄存器 | |||
| struct CoilNodeConfig | |||
| { | |||
| // 线圈要写入的 M 寄存器地址 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| CoilMode mode = CoilMode::Normal; | |||
| }; | |||
| // D 寄存器比较配置 | |||
| struct CompareNodeConfig | |||
| { | |||
| // 要参与比较的 D 寄存器地址 | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| // 比较运算符 | |||
| ComparisonOperator comparison = ComparisonOperator::Equal; | |||
| // 与寄存器值比较的常量 | |||
| std::int16_t value = 0; | |||
| }; | |||
| // MOVE 输出:把一个字操作数写入目标 D 地址 | |||
| struct MoveNodeConfig | |||
| { | |||
| WordOperand source; // 要写入的源操作数 | |||
| RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 | |||
| WordOperand source; | |||
| RegisterAddress destination{RegisterArea::D, 0}; | |||
| }; | |||
| enum class ArithmeticOperation | |||
| { | |||
| Add, // 加法 | |||
| Subtract // 减法 | |||
| Add, | |||
| Subtract | |||
| }; | |||
| // ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 | |||
| 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< | |||
| ContactNodeConfig, // 普通常开或常闭触点 | |||
| EdgeContactNodeConfig, // 上升沿或下降沿触点 | |||
| CoilNodeConfig, // 普通、置位或复位线圈 | |||
| CompareNodeConfig, // D 寄存器比较节点 | |||
| MoveNodeConfig, // MOVE 数据传送输出 | |||
| ArithmeticNodeConfig>; // ADD 或 SUB 算术输出 | |||
| ContactNodeConfig, | |||
| EdgeContactNodeConfig, | |||
| CoilNodeConfig, | |||
| CompareNodeConfig, | |||
| MoveNodeConfig, | |||
| ArithmeticNodeConfig>; | |||
| // 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数 | |||
| std::optional<RegisterAddress> registerAddressForLogicNode( | |||
| const LogicNodeConfig &config); | |||
| // 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建 | |||
| void collectRegisterAddressesForLogicNode( | |||
| const LogicNodeConfig &config, | |||
| std::vector<RegisterAddress> *addresses); | |||
| // 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿 | |||
| 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 isConfigured() const; | |||
| // 判断节点能否放在条件区或固定输出槽 | |||
| bool isCondition() 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; | |||
| }; | |||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | |||
| 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 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 | |||
| { | |||
| 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 validateStructure(std::string *error = nullptr) const; | |||
| // 运行态要求有输出且所有引用都已配置 | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| // 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它 | |||
| // 一张连续梯形图,网络由横竖连接关系自然形成 | |||
| 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( | |||
| const ProjectLimitSettings &limits, | |||
| @@ -264,9 +183,21 @@ struct ControlLogic | |||
| bool validateStructure( | |||
| const ProjectLimitSettings &limits, | |||
| std::string *error = nullptr) const; | |||
| // 校验运行所需资源和网络配置 | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| bool validateForRunning( | |||
| const ProjectLimitSettings &limits, | |||
| 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> | |||
| // 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 | |||
| // 程序允许的绝对硬上限统一放在这里,避免 UI、服务和文件读写各自使用不同数字 | |||
| // application.ini 只能收紧其中支持配置的上限,不能突破这些硬上限 | |||
| namespace ProjectLimits { | |||
| 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 kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释 | |||
| 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); // 确保总列数始终等于条件列加输出列 | |||
| 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 int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 | |||
| } | |||
| } // namespace ProjectLimits | |||
| // 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限 | |||
| // 本次程序实际使用的可配置上限;默认采用代码硬上限,INI 只能将它们调小 | |||
| struct ProjectLimitSettings | |||
| { | |||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; | |||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; // 一个工程允许的 HMI 页面数 | |||
| std::size_t maximumHmiControlsPerPage = | |||
| ProjectLimits::kMaximumHmiControlsPerPage; | |||
| ProjectLimits::kMaximumHmiControlsPerPage; // 每个 HMI 页面允许的控件数 | |||
| 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 | |||
| { | |||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; | |||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; | |||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; // 新建页面的默认宽度 | |||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; // 新建页面的默认高度 | |||
| }; | |||
| // 返回长期有效的默认上限,供没有传入 INI 配置的代码使用 | |||
| inline const ProjectLimitSettings &defaultProjectLimitSettings() | |||
| { | |||
| static const ProjectLimitSettings settings; | |||
| return settings; | |||
| } | |||
| // 返回长期有效的默认页面尺寸,供没有传入 INI 配置的代码使用 | |||
| inline const HmiDefaultSettings &defaultHmiSettings() | |||
| { | |||
| static const HmiDefaultSettings settings; | |||
| @@ -401,10 +401,7 @@ bool Project::validate( | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| if (rung.condition.has_value()) | |||
| { | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| } | |||
| collectConditionNodes(rung, &nodes); | |||
| if (rung.output.has_value()) | |||
| { | |||
| nodes.push_back(&*rung.output); | |||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||
| // 工程显示名称 | |||
| std::string name; | |||
| // 工程文件格式版本 | |||
| std::string formatVersion = "1.0"; | |||
| std::string formatVersion = "2.0"; | |||
| }; | |||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | |||
| @@ -15,41 +15,41 @@ ModeTransitionResult RuntimeState::enterEditing() | |||
| // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 | |||
| if (mode_ == ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||
| } | |||
| mode_ = ApplicationMode::Editing; | |||
| return {true, ModeTransitionError::None}; | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| ModeTransitionResult RuntimeState::enterOfflineRunning() | |||
| { | |||
| if (mode_ == ApplicationMode::OfflineRunning) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||
| } | |||
| if (mode_ != ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||
| return {false, ModeTransitionError::MustReturnToEditing, {}}; | |||
| } | |||
| mode_ = ApplicationMode::OfflineRunning; | |||
| return {true, ModeTransitionError::None}; | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) | |||
| { | |||
| if (mode_ == ApplicationMode::OnlineRunning) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||
| } | |||
| if (mode_ != ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||
| return {false, ModeTransitionError::MustReturnToEditing, {}}; | |||
| } | |||
| if (!initial_plc_read_completed) | |||
| { | |||
| // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 | |||
| return {false, ModeTransitionError::InitialPlcReadRequired}; | |||
| return {false, ModeTransitionError::InitialPlcReadRequired, {}}; | |||
| } | |||
| mode_ = ApplicationMode::OnlineRunning; | |||
| return {true, ModeTransitionError::None}; | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| @@ -1,5 +1,7 @@ | |||
| #pragma once | |||
| #include <string> | |||
| // 应用当前运行模式;三个状态之间不能直接从离线切到真机 | |||
| enum class ApplicationMode | |||
| { | |||
| @@ -86,6 +88,8 @@ struct ModeTransitionResult | |||
| bool succeeded = false; | |||
| // 切换失败原因 | |||
| ModeTransitionError error = ModeTransitionError::None; | |||
| // 服务层补充的具体失败原因,状态机自身可以留空 | |||
| std::string detail; | |||
| }; | |||
| // 保存当前模式并执行最基本的状态机约束 | |||
| @@ -18,7 +18,7 @@ | |||
| namespace { | |||
| // 当前读写实现支持的工程文件格式版本 | |||
| constexpr const char *kCurrentFormatVersion = "1.0"; | |||
| constexpr const char *kCurrentFormatVersion = "2.0"; | |||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | |||
| struct ParseState | |||
| @@ -1283,185 +1283,30 @@ bool parseLogicNode( | |||
| 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 object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | |||
| 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( | |||
| QStringLiteral("output"), | |||
| rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output)) | |||
| @@ -1475,42 +1320,71 @@ bool parseLadderRung( | |||
| LadderRung *rung, | |||
| ParseState *state) | |||
| { | |||
| QJsonValue condition; | |||
| QJsonArray cells; | |||
| QJsonValue output; | |||
| if (!readString( | |||
| object, "id", context, &rung->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", context, &rung->name, state) | |||
| || !readString(object, "comment", context, &rung->comment, state) | |||
| || !readArray(object, "cells", context, &cells, state, | |||
| ProjectLimits::kMaximumConditionColumns) | |||
| || !readValue(object, "output", context, &output, state)) | |||
| { | |||
| 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; | |||
| } | |||
| 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()) | |||
| { | |||
| @@ -1539,11 +1413,22 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| { | |||
| 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; | |||
| object.insert(QStringLiteral("id"), fromUtf8(logic.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(logic.name)); | |||
| object.insert(QStringLiteral("enabled"), logic.enabled); | |||
| object.insert(QStringLiteral("rungs"), rungs); | |||
| object.insert(QStringLiteral("verticalConnections"), connections); | |||
| return object; | |||
| } | |||
| @@ -1555,6 +1440,7 @@ bool parseControlLogic( | |||
| ParseState *state) | |||
| { | |||
| QJsonArray rungs; | |||
| QJsonArray connections; | |||
| if (!readString( | |||
| object, "id", context, &logic->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| @@ -1562,7 +1448,10 @@ bool parseControlLogic( | |||
| || !readBool(object, "enabled", context, &logic->enabled, state) | |||
| || !readArray( | |||
| 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; | |||
| } | |||
| @@ -1586,6 +1475,35 @@ bool parseControlLogic( | |||
| } | |||
| 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; | |||
| } | |||
| @@ -2,7 +2,7 @@ | |||
| #include "domain/project_storage.h" | |||
| // 严格读写当前 1.0 JSON 工程格式 | |||
| // 严格读写当前 2.0 JSON 工程格式 | |||
| class JsonProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| @@ -141,7 +141,10 @@ int main(int argc, char *argv[]) | |||
| OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository); | |||
| // 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期 | |||
| 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 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 | |||
| runtime_mode_service.configurePlc( | |||
| plc_communication_service, | |||
| @@ -201,7 +201,7 @@ bool parseInt16( | |||
| LogicCommandResult executionFailure( | |||
| const std::string &message, LogicCommandOpcode opcode) | |||
| { | |||
| return {false, message, {}, {}, opcode}; | |||
| return {false, message, {}, {}, opcode, {}, false}; | |||
| } | |||
| } // namespace | |||
| @@ -425,6 +425,8 @@ LogicCommandResult LogicCommandService::execute( | |||
| } | |||
| const LogicCommandOpcode opcode = parsed.command.opcode; | |||
| LogicEditorResult edited; | |||
| LogicEditCursor next_cursor; | |||
| bool has_next_cursor = false; | |||
| if (request.target.kind == LogicCommandTargetKind::ExistingNode) | |||
| { | |||
| @@ -439,7 +441,7 @@ LogicCommandResult LogicCommandService::execute( | |||
| if (!isLoad(opcode)) | |||
| { | |||
| return executionFailure( | |||
| "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); | |||
| "已有条件节点只能替换为触点或比较指令", opcode); | |||
| } | |||
| } | |||
| else if (existing->isOutput()) | |||
| @@ -461,12 +463,7 @@ LogicCommandResult LogicCommandService::execute( | |||
| } | |||
| 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 | |||
| ? request.currentRungId : request.target.rungId; | |||
| @@ -489,20 +486,19 @@ LogicCommandResult LogicCommandService::execute( | |||
| parsed.command.config, | |||
| 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 | |||
| { | |||
| @@ -513,46 +509,35 @@ LogicCommandResult LogicCommandService::execute( | |||
| } | |||
| if ((opcode == LogicCommandOpcode::And | |||
| || 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 | |||
| @@ -573,8 +558,14 @@ LogicCommandResult LogicCommandService::execute( | |||
| "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", | |||
| 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) | |||
| @@ -584,5 +575,7 @@ LogicCommandResult LogicCommandService::execute( | |||
| return { | |||
| true, {}, edited.id, | |||
| editor_service_.rungIdForNode(request.logicId, edited.id), | |||
| opcode}; | |||
| opcode, | |||
| std::move(next_cursor), | |||
| has_next_cursor}; | |||
| } | |||
| @@ -1,12 +1,10 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include "logic_editor_service.h" | |||
| #include <string> | |||
| #include <vector> | |||
| class LogicEditorService; | |||
| enum class LogicCommandOpcode | |||
| { | |||
| Load, | |||
| @@ -88,6 +86,8 @@ struct LogicCommandResult | |||
| std::string id; | |||
| std::string rungId; | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||
| LogicEditCursor nextCursor; | |||
| bool hasNextCursor = false; | |||
| }; | |||
| // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 | |||
| @@ -9,357 +9,361 @@ | |||
| class ProjectService; | |||
| // 梯形图编辑失败分类 | |||
| 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 | |||
| { | |||
| 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; | |||
| 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 | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建梯形图编辑服务 | |||
| * @param project_service 用于读取和修改当前工程的项目服务 | |||
| */ | |||
| explicit LogicEditorService(ProjectService &project_service); | |||
| // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 | |||
| /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ | |||
| const ControlLogic *findLogic(const std::string &logic_id) const; | |||
| /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ | |||
| const LadderRung *findRung( | |||
| 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 &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; | |||
| /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ | |||
| std::string firstRungId(const std::string &logic_id) const; | |||
| /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ | |||
| std::string rungIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| /** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */ | |||
| std::string registerCommentFor(const RegisterAddress &address) const; | |||
| /** | |||
| * @brief 确保工程至少有一组可编辑逻辑 | |||
| * @return 已有或新建逻辑的成功结果及其 ID | |||
| * | |||
| * 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络 | |||
| */ | |||
| LogicEditorResult ensureDefaultLogic(); | |||
| /** @brief 添加控制逻辑;名称不能为空且必须唯一 */ | |||
| LogicEditorResult addLogic(const std::string &name); | |||
| /** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */ | |||
| LogicEditorResult renameLogic( | |||
| const std::string &logic_id, const std::string &name); | |||
| /** @brief 删除控制逻辑;工程至少保留一组逻辑 */ | |||
| LogicEditorResult removeLogic(const std::string &logic_id); | |||
| /** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */ | |||
| LogicEditorResult moveLogic(const std::string &logic_id, int offset); | |||
| /** @brief 启用或停用指定控制逻辑 */ | |||
| 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); | |||
| /** @brief 删除指定网络 */ | |||
| LogicEditorResult insertRung( | |||
| const std::string &logic_id, | |||
| const std::string &reference_rung_id, | |||
| bool after); | |||
| LogicEditorResult removeRung( | |||
| const std::string &logic_id, const std::string &rung_id); | |||
| /** @brief 批量删除指定网络,失败时整体回滚 */ | |||
| LogicEditorResult removeRungs( | |||
| const std::string &logic_id, | |||
| const std::vector<std::string> &rung_ids); | |||
| /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ | |||
| LogicEditorResult updateRungComment( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &comment); | |||
| // 条件区编辑:串联、按列插入、横线和并联分支 | |||
| /** @brief 在网络条件末尾追加一个条件节点 */ | |||
| LogicEditorResult appendCondition( | |||
| LogicEditorResult setConditionAtColumn( | |||
| const std::string &logic_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, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 按绝对条件列插入条件节点 | |||
| * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 | |||
| */ | |||
| LogicEditorResult insertConditionAtColumn( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 在直属并联分支的指定视觉列插入条件节点 | |||
| * | |||
| * 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线 | |||
| */ | |||
| LogicEditorResult insertConditionInBranchAtColumn( | |||
| LogicEditorResult appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &branch_expression_id, | |||
| int column, | |||
| const LogicNodeConfig &config, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column_span = 1); | |||
| /** @brief 在目标节点后串联插入条件节点 */ | |||
| LogicEditorResult insertConditionAfter( | |||
| LogicEditResult applyWireAndAdvance( | |||
| 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 &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 &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 &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 &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 &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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_node_ids, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 将当前网络的完整条件表达式与一个新条件并联 */ | |||
| LogicEditorResult addParallelToWholeCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| // 节点属性和删除操作 | |||
| /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ | |||
| LogicEditResult applyOutputAndAdvance( | |||
| const std::string &logic_id, | |||
| const LogicEditCursor &cursor, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| LogicEditorResult updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<LogicNode> &nodes, | |||
| const LogicConditionPasteTarget &target = {}); | |||
| /** 判断所选条件是否位于同一串联层级并且视觉连续 */ | |||
| int start_column = -1); | |||
| bool areConditionNodesContiguous( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &node_ids) const; | |||
| /** | |||
| * @brief 将整条网络复制到目标控制逻辑末尾 | |||
| * @param logic_id 目标控制逻辑 | |||
| * @param source 要复制的网络 | |||
| * @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID | |||
| */ | |||
| LogicEditorResult pasteRung( | |||
| const std::string &logic_id, | |||
| const LadderRung &source); | |||
| /** @brief 删除指定节点并归一化受影响的表达式树 */ | |||
| LogicEditorResult removeNode( | |||
| const std::string &logic_id, const std::string &node_id); | |||
| /** | |||
| * @brief 批量删除节点 | |||
| * @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID | |||
| * @return 成功时作为一次编辑记录,失败时整体回滚 | |||
| */ | |||
| LogicEditorResult removeNodes( | |||
| const std::string &logic_id, | |||
| 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; | |||
| /** @brief 判断是否存在可重做的梯形图编辑操作 */ | |||
| bool canRedo() const; | |||
| /** @brief 撤销最近一次成功的梯形图编辑操作 */ | |||
| LogicEditorResult undo(); | |||
| /** @brief 重做最近一次被撤销的梯形图编辑操作 */ | |||
| LogicEditorResult redo(); | |||
| /** @brief 清空撤销/重做历史,不修改当前工程内容 */ | |||
| void clearHistory(); | |||
| private: | |||
| // 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史 | |||
| struct HistoryState | |||
| { | |||
| std::vector<ControlLogic> logics; // 编辑前或编辑后的完整逻辑集合 | |||
| std::vector<ControlLogic> logics; | |||
| }; | |||
| // 捕获当前工程中的全部控制逻辑 | |||
| HistoryState captureState() const; | |||
| // 比较编辑前后状态并记录实际发生的修改 | |||
| void recordHistory(HistoryState before); | |||
| // 失败时恢复工程内容和操作前脏标记,保证编辑原子性 | |||
| void rollbackEdit(HistoryState before, bool modified_before); | |||
| // 比较两个历史快照是否完全相同 | |||
| static bool statesEqual( | |||
| const HistoryState &left, const HistoryState &right); | |||
| static bool logicsEqual( | |||
| const ControlLogic &left, const ControlLogic &right); | |||
| static bool rungsEqual( | |||
| 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 configsEqual( | |||
| const LogicNodeConfig &left, const LogicNodeConfig &right); | |||
| static LogicEditorResult historyFailure(const std::string &message); | |||
| // 判断节点配置是否属于条件节点 | |||
| static bool isConditionConfig(const LogicNodeConfig &config); | |||
| // 判断节点配置是否属于输出节点 | |||
| static bool isOutputConfig(const LogicNodeConfig &config); | |||
| // 返回节点配置对应的稳定 ID 前缀 | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| // 在一个控制逻辑的全部网络中生成唯一节点 ID | |||
| static std::string makeUniqueNodeId( | |||
| static std::string makeUniqueId( | |||
| 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( | |||
| 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) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| if (rung.condition.has_value()) | |||
| { | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| } | |||
| collectConditionNodes(rung, &nodes); | |||
| if (rung.output.has_value()) | |||
| { | |||
| nodes.push_back(&*rung.output); | |||
| @@ -202,7 +202,7 @@ Project ProjectService::makeNewProject(const std::string &name) | |||
| project.metadata.id = generateProjectId(); | |||
| project.metadata.name = name; | |||
| // 新工程固定使用当前存储格式版本 | |||
| project.metadata.formatVersion = "1.0"; | |||
| project.metadata.formatVersion = "2.0"; | |||
| return project; | |||
| } | |||
| @@ -13,12 +13,15 @@ | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <utility> | |||
| RuntimeModeService::RuntimeModeService( | |||
| const ProjectService &project_service, | |||
| LogicEditorService &logic_editor_service, | |||
| OfflineSimulationService &offline_simulation_service, | |||
| OnlineLogicMonitorService &online_logic_monitor_service) | |||
| : project_service_(project_service), | |||
| logic_editor_service_(logic_editor_service), | |||
| offline_simulation_service_(offline_simulation_service), | |||
| online_logic_monitor_service_(online_logic_monitor_service) | |||
| { | |||
| @@ -68,11 +71,10 @@ ModeTransitionResult RuntimeModeService::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 缓存 | |||
| if (active_repository_ != nullptr && virtual_repository_ != nullptr) | |||
| @@ -84,7 +86,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| project_service_.project().controlLogics); | |||
| if (!start_result.succeeded) | |||
| { | |||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||
| return {false, ModeTransitionError::SimulationStartFailed, {}}; | |||
| } | |||
| const ModeTransitionResult transition = state_.enterOfflineRunning(); | |||
| if (!transition.succeeded) | |||
| @@ -104,12 +106,17 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning() | |||
| { | |||
| return state_.enterOnlineRunning(false); | |||
| } | |||
| const ModeTransitionResult preparation = prepareProjectForRunning(); | |||
| if (!preparation.succeeded) | |||
| { | |||
| return preparation; | |||
| } | |||
| const OnlineLogicMonitorStartResult start_result = | |||
| online_logic_monitor_service_.start( | |||
| project_service_.project().controlLogics); | |||
| if (!start_result.succeeded) | |||
| { | |||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||
| return {false, ModeTransitionError::SimulationStartFailed, {}}; | |||
| } | |||
| const ModeTransitionResult result = state_.enterOnlineRunning(true); | |||
| if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) | |||
| @@ -162,6 +169,30 @@ OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService() | |||
| 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( | |||
| PlcCommunicationGateway &gateway, | |||
| ActiveRegisterRepository &active_repository, | |||
| @@ -304,10 +335,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| if (rung.condition.has_value()) | |||
| { | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| } | |||
| collectConditionNodes(rung, &nodes); | |||
| if (rung.output.has_value()) | |||
| { | |||
| nodes.push_back(&*rung.output); | |||
| @@ -9,6 +9,7 @@ | |||
| #pragma once | |||
| #include "domain/runtime_state.h" | |||
| #include "logic_editor_service.h" | |||
| #include "offline_simulation_service.h" | |||
| #include "online_logic_monitor_service.h" | |||
| #include "plc_communication_gateway.h" | |||
| @@ -32,6 +33,7 @@ public: | |||
| */ | |||
| RuntimeModeService( | |||
| const ProjectService &project_service, | |||
| LogicEditorService &logic_editor_service, | |||
| OfflineSimulationService &offline_simulation_service, | |||
| OnlineLogicMonitorService &online_logic_monitor_service); | |||
| @@ -86,6 +88,8 @@ public: | |||
| /** @brief 返回服务持有的离线仿真服务引用 */ | |||
| OfflineSimulationService &offlineSimulationService(); | |||
| OnlineLogicMonitorService &onlineLogicMonitorService(); | |||
| /** 返回最近一次进入运行模式前执行的梯形图语法检查结果 */ | |||
| const LogicSyntaxCheckResult &lastSyntaxCheck() const; | |||
| /** | |||
| * @brief 注入 PLC 网关、活动仓库和两种实际数据源 | |||
| @@ -137,7 +141,10 @@ public: | |||
| void setPlcStatusChangedCallback(std::function<void()> callback); | |||
| private: | |||
| ModeTransitionResult prepareProjectForRunning(); | |||
| const ProjectService &project_service_; // 不拥有的只读工程服务 | |||
| LogicEditorService &logic_editor_service_; // 统一执行运行前规整和语法检查 | |||
| OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 | |||
| OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务 | |||
| RuntimeState state_; // 编辑、离线和真机模式状态机 | |||
| @@ -150,4 +157,5 @@ private: | |||
| std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知 | |||
| std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址 | |||
| std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址 | |||
| LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 | |||
| }; | |||
| @@ -75,9 +75,10 @@ void LogicTraceValues::clear() | |||
| { | |||
| nodeValues.clear(); | |||
| nodePowerValues.clear(); | |||
| expressionValues.clear(); | |||
| expressionInputValues.clear(); | |||
| expressionPowerValues.clear(); | |||
| cellValues.clear(); | |||
| cellInputPowerValues.clear(); | |||
| cellPowerValues.clear(); | |||
| verticalConnectionValues.clear(); | |||
| rungValues.clear(); | |||
| wordValues.clear(); | |||
| } | |||
| @@ -97,9 +98,11 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| { | |||
| projection.nodeValues = values->second.nodeValues; | |||
| 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.wordValues = values->second.wordValues; | |||
| } | |||
| @@ -192,7 +195,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| { | |||
| trace->clear(); | |||
| } | |||
| // 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见 | |||
| // 互相没有竖线连接的行仍按先后顺序扫描,保持原有网络间可见性 | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| @@ -201,56 +204,195 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| } | |||
| LogicTraceValues *logic_trace = trace == nullptr | |||
| ? 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) | |||
| @@ -265,9 +407,11 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| { | |||
| trace->nodeValues = values->second.nodeValues; | |||
| 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->wordValues = values->second.wordValues; | |||
| } | |||
| @@ -276,89 +420,6 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| 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( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| @@ -38,15 +38,16 @@ struct WordTraceValue | |||
| bool overflow = false; // ADD/SUB 是否发生饱和溢出 | |||
| }; | |||
| // 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键 | |||
| // 一张连续梯形图的运行轨迹快照 | |||
| 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 节点状态 | |||
| // 清除当前逻辑或网络的全部轨迹 | |||
| @@ -109,23 +110,6 @@ private: | |||
| const LogicNode &node, | |||
| RegisterRepository &repository, | |||
| 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 在网络结果驱动下执行单个输出节点 | |||
| * @param node 待读取配置并执行的输出节点 | |||
| @@ -1,11 +1,3 @@ | |||
| /** | |||
| * @file logic_editor_widget.h | |||
| * @brief 定义结构化梯形图编辑和运行轨迹画布 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| @@ -14,6 +6,7 @@ | |||
| #include "services/software_logic_executor.h" | |||
| #include <QGraphicsView> | |||
| #include <QPoint> | |||
| #include <string> | |||
| #include <utility> | |||
| @@ -27,180 +20,149 @@ class QMouseEvent; | |||
| class QRubberBand; | |||
| class QResizeEvent; | |||
| /** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ | |||
| class LogicEditorWidget final : public QGraphicsView | |||
| { | |||
| Q_OBJECT | |||
| 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( | |||
| LogicEditorService &editor_service, | |||
| QWidget *parent = nullptr); | |||
| /** 切换当前显示的控制逻辑;不存在时显示空场景 */ | |||
| void setLogicId(const std::string &logic_id); | |||
| /** 设置是否允许编辑梯形图 */ | |||
| void setEditingEnabled(bool enabled); | |||
| /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ | |||
| void setMouseWireMode(MouseWireMode mode); | |||
| MouseWireMode mouseWireMode() const; | |||
| void setRuntimeTrace( | |||
| const LogicTraceSnapshot &trace, | |||
| const std::string &fault_node_id = {}); | |||
| /** 清除当前运行轨迹和故障节点标记 */ | |||
| void clearRuntimeTrace(); | |||
| /** 返回当前是否正在显示运行轨迹 */ | |||
| bool runtimeTraceEnabled() const; | |||
| /** 根据当前逻辑模型重建梯形图场景 */ | |||
| void reloadLogic(); | |||
| /** 选中指定节点并滚动到可见区域 */ | |||
| void clearSelection(); | |||
| void selectNode(const std::string &node_id); | |||
| /** 返回当前选中的单个节点标识 */ | |||
| /** 选中并滚动到语法错误位置,column 使用界面显示的 1 基列号 */ | |||
| void focusSyntaxLocation(const std::string &rung_id, int column); | |||
| std::string selectedNodeId() const; | |||
| /** 返回当前选中的多个节点标识 */ | |||
| std::vector<std::string> selectedNodeIds() const; | |||
| /** 返回当前选中的网络标识 */ | |||
| std::string selectedRungId() const; | |||
| bool hasCopyableSelection() const; | |||
| LogicClipboardCopyResult copySelection() const; | |||
| LogicPasteTarget pasteTarget() const; | |||
| /** 新增一个空的梯形图网络 */ | |||
| LogicEditorResult addRung(); | |||
| /** 将当前选择转换为服务调用并新增串联条件 */ | |||
| LogicEditorResult insertRung(bool after); | |||
| LogicEditorResult deleteRung(); | |||
| LogicEditorResult addCondition(const LogicNodeConfig &config); | |||
| /** 将当前选择转换为服务调用并新增并联支路 */ | |||
| LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | |||
| /** 在当前选择位置新增横线 */ | |||
| LogicEditorResult addHorizontalWire(); | |||
| /** 在当前选择位置新增竖线 */ | |||
| LogicEditorResult addVerticalWire(); | |||
| /** 删除当前选择位置的横线 */ | |||
| LogicEditorResult deleteHorizontalWire(); | |||
| /** 删除当前选择位置的竖线 */ | |||
| LogicEditorResult deleteVerticalWire(); | |||
| /** 设置当前网络的输出线圈 */ | |||
| LogicEditorResult setOutput( | |||
| const LogicNodeConfig &config, bool configured = false); | |||
| /** 按当前画布选择位置粘贴一组连续条件 */ | |||
| LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes); | |||
| /** 删除当前选中的节点、线段或网络 */ | |||
| LogicClipboardPasteResult pasteClipboard( | |||
| const LogicClipboardFragment &fragment); | |||
| LogicEditorResult deleteSelected(); | |||
| signals: | |||
| /** 当前选中的节点发生变化时发出 */ | |||
| void nodeSelected(const QString &node_id); | |||
| /** 梯形图模型成功发生变化时发出 */ | |||
| void graphChanged(); | |||
| /** 编辑服务操作失败时发出可显示的错误信息 */ | |||
| void editorError(const QString &message); | |||
| protected: | |||
| /** 在任意网络区域开始鼠标框选 */ | |||
| void mousePressEvent(QMouseEvent *event) override; | |||
| /** 更新鼠标框选区域 */ | |||
| void mouseMoveEvent(QMouseEvent *event) override; | |||
| /** 完成鼠标框选并提交场景选择 */ | |||
| void mouseReleaseEvent(QMouseEvent *event) override; | |||
| /** 在条件网格或输出槽双击时打开命令输入框 */ | |||
| void mouseDoubleClickEvent(QMouseEvent *event) override; | |||
| /** 在窗口大小变化后重新布局梯形图场景 */ | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| /** 滚动画布时同步命令输入框位置 */ | |||
| void scrollContentsBy(int dx, int dy) override; | |||
| /** 处理命令输入框的回车、取消和按键事件 */ | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| 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); | |||
| /** 返回当前选中的网络标识 */ | |||
| 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 cancelCommandInput(); | |||
| /** 根据网络、列和支路标识查找下一输入位置 */ | |||
| bool beginNextCommandInput(); | |||
| /** 按目标定位输入框的屏幕矩形 */ | |||
| void positionCommandInput(const QPointF &scene_center); | |||
| /** 返回当前场景目标的中心位置 */ | |||
| bool findCommandTargetCenter( | |||
| const LogicCommandTarget &target, | |||
| 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_; | |||
| /** 命令语解析和结构化编辑适配服务 */ | |||
| LogicCommandService command_service_; | |||
| /** 承载梯形图图元的场景 */ | |||
| QGraphicsScene *scene_ = nullptr; | |||
| /** 当前显示的控制逻辑标识 */ | |||
| 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_; | |||
| /** 最近一次运行故障节点标识 */ | |||
| std::string fault_node_id_; | |||
| /** 是否显示运行轨迹 */ | |||
| bool runtime_trace_enabled_ = false; | |||
| /** 是否允许编辑梯形图 */ | |||
| 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; | |||
| /** 框选起点(视口坐标) */ | |||
| QPoint selection_origin_; | |||
| /** 框选时是否已超过拖拽阈值 */ | |||
| bool selection_pressed_ = false; | |||
| bool selection_dragging_ = false; | |||
| /** 框选起始时的键盘修饰键 */ | |||
| Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; | |||
| /** 命令输入框,仅在编辑态临时创建 */ | |||
| bool gesture_active_ = false; | |||
| Hit gesture_origin_; | |||
| Hit gesture_current_; | |||
| QLineEdit *command_editor_ = nullptr; | |||
| /** 命令输入补全器 */ | |||
| QCompleter *command_completer_ = nullptr; | |||
| /** 当前命令输入目标 */ | |||
| 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_; | |||
| }; | |||
| @@ -40,6 +40,8 @@ | |||
| #include <QIcon> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <QListWidget> | |||
| #include <QListWidgetItem> | |||
| #include <QKeyEvent> | |||
| #include <QKeySequence> | |||
| #include <QMessageBox> | |||
| @@ -55,11 +57,16 @@ | |||
| #include <QDir> | |||
| #include <QFileInfo> | |||
| #include <QProgressDialog> | |||
| #include <QSignalBlocker> | |||
| #include <algorithm> | |||
| namespace { | |||
| constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1; | |||
| constexpr int kSyntaxRungIdRole = Qt::UserRole + 2; | |||
| constexpr int kSyntaxColumnRole = Qt::UserRole + 3; | |||
| bool isTextEditingObject(QObject *object) | |||
| { | |||
| QWidget *widget = qobject_cast<QWidget *>(object); | |||
| @@ -612,6 +619,33 @@ void MainWindow::configureActions() | |||
| this, &MainWindow::deleteActiveSelection); | |||
| connect(ui_->clearSelectionAction, &QAction::triggered, | |||
| 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, | |||
| [this] { addHmiControl(HmiControlType::Button); }); | |||
| @@ -641,6 +675,44 @@ void MainWindow::configureActions() | |||
| connect(ui_->addRungAction, &QAction::triggered, | |||
| 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, | |||
| this, &MainWindow::addLogicHorizontalWire); | |||
| connect(ui_->insertVerticalWireAction, &QAction::triggered, | |||
| @@ -911,6 +983,11 @@ void MainWindow::configureAppearance() | |||
| ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); | |||
| ui_->deleteControlAction->setIcon(makeUiIcon(UiIcon::Delete)); | |||
| 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_->insertHorizontalWireAction->setIcon(makeUiIcon(UiIcon::HorizontalWire)); | |||
| ui_->insertVerticalWireAction->setIcon(makeUiIcon(UiIcon::VerticalWire)); | |||
| @@ -928,6 +1005,8 @@ void MainWindow::configureAppearance() | |||
| ui_->addSubAction->setIcon(makeUiIcon(UiIcon::Subtract)); | |||
| ui_->addCompareAction->setIcon(makeUiIcon(UiIcon::Compare)); | |||
| 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_->editorTabWidget->setTabIcon(0, makeUiIcon(UiIcon::HmiPage)); | |||
| ui_->editorTabWidget->setTabIcon(1, makeUiIcon(UiIcon::Logic)); | |||
| @@ -992,6 +1071,12 @@ void MainWindow::configureLogicEditor() | |||
| layout->addWidget(logic_editor_widget_); | |||
| property_panel_controller_->bindEditorWidgets( | |||
| *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() | |||
| @@ -1275,57 +1360,29 @@ void MainWindow::copyActiveSelection() | |||
| } | |||
| 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; | |||
| } | |||
| 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(); | |||
| @@ -1362,37 +1419,24 @@ void MainWindow::pasteActiveSelection() | |||
| } | |||
| 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; | |||
| } | |||
| selected_logic_node_id_ = logic_editor_widget_->selectedNodeId(); | |||
| showLogicNodeProperties(selected_logic_node_id_); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000); | |||
| } | |||
| @@ -1421,7 +1465,7 @@ void MainWindow::clearActiveSelection() | |||
| } | |||
| else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | |||
| { | |||
| logic_editor_widget_->scene()->clearSelection(); | |||
| logic_editor_widget_->clearSelection(); | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| } | |||
| @@ -1439,19 +1483,32 @@ void MainWindow::updateEditActions() | |||
| ui_->redoAction->setEnabled( | |||
| editable && ((hmi_active && hmi_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 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) | |||
| || (logic_active && logic_clipboard))); | |||
| ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| ui_->insertHorizontalWireAction->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_->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() | |||
| @@ -1645,7 +1702,7 @@ void MainWindow::addLogicVerticalWire() | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已建立横线旁路和竖线连接"), 3000); | |||
| statusBar()->showMessage(tr("竖线连接已建立"), 3000); | |||
| } | |||
| void MainWindow::deleteLogicHorizontalWire() | |||
| @@ -1673,7 +1730,7 @@ void MainWindow::deleteLogicVerticalWire() | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("竖线及对应并联支路已删除"), 3000); | |||
| statusBar()->showMessage(tr("竖线已删除,网络已拆分"), 3000); | |||
| } | |||
| void MainWindow::setLogicOutput(const LogicNodeConfig &config) | |||
| @@ -1725,6 +1782,48 @@ void MainWindow::addLogicRung() | |||
| 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() | |||
| { | |||
| const std::string rung_id = logic_editor_widget_->selectedRungId(); | |||
| @@ -1763,6 +1862,62 @@ void MainWindow::editSelectedRungComment() | |||
| 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() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->deleteSelected(); | |||
| @@ -1902,6 +2057,16 @@ void MainWindow::loadProject() | |||
| 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; | |||
| if (!project_service_.project().validateForRunning( | |||
| 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_ | |||
| .settings.projectLimits.maximumOutputMessages; | |||
| @@ -2114,7 +2281,19 @@ void MainWindow::appendOutputMessage(const QString &message) | |||
| { | |||
| 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(); | |||
| } | |||
| @@ -2147,6 +2326,19 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) | |||
| 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) | |||
| { | |||
| restoreCurrentModeAction(); | |||
| @@ -2155,6 +2347,10 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) | |||
| { | |||
| message += tr(";当前状态:%1").arg(plcStatusText(runtime_mode_service_)); | |||
| } | |||
| if (!result.detail.empty()) | |||
| { | |||
| message += QStringLiteral(": ") + fromUtf8(result.detail); | |||
| } | |||
| if (result.error == ModeTransitionError::SimulationStartFailed) | |||
| { | |||
| const LogicScanResult &error = requested_mode | |||
| @@ -2211,6 +2407,13 @@ void MainWindow::updateModeUi(const QString &message) | |||
| hmi_editor_widget_->setRuntimeActive( | |||
| policy.usesVirtualRegisters || policy.usesPlcRegisters); | |||
| 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) | |||
| { | |||
| alarm_service_.reset(); | |||
| @@ -13,10 +13,12 @@ | |||
| #include "domain/runtime_state.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| #include "services/application_settings.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include <QMainWindow> | |||
| #include <memory> | |||
| #include <optional> | |||
| #include <variant> | |||
| #include <vector> | |||
| @@ -193,8 +195,24 @@ private: | |||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | |||
| /** 新增一个梯形图网络 */ | |||
| void addLogicRung(); | |||
| /** 在当前网络上方插入一行 */ | |||
| void insertLogicRungAbove(); | |||
| /** 在当前网络下方插入一行 */ | |||
| void insertLogicRungBelow(); | |||
| /** 删除当前选中的梯形图行 */ | |||
| void deleteLogicRung(); | |||
| /** 编辑当前网络的注释 */ | |||
| void editSelectedRungComment(); | |||
| /** 规整并检查当前梯形图 */ | |||
| void runLogicSyntaxCheck(); | |||
| /** 单独检查当前梯形图中的重复线圈输出 */ | |||
| void runDoubleCoilCheck(); | |||
| /** 将语法检查结果写入输出栏并定位错误 */ | |||
| void reportLogicSyntaxCheck( | |||
| const LogicSyntaxCheckResult &result, | |||
| const QString &action); | |||
| /** 切换到并聚焦语法错误所在的逻辑位置 */ | |||
| void focusLogicSyntaxLocation(const LogicSyntaxLocation &location); | |||
| /** 删除当前选中的逻辑节点或网络 */ | |||
| void deleteSelectedLogicObject(); | |||
| /** 打开 PLC 连接 */ | |||
| @@ -216,7 +234,9 @@ private: | |||
| /** 在状态栏和输出面板显示工程操作结果 */ | |||
| 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 请求服务层切换模式并同步界面状态 | |||
| @@ -321,23 +341,13 @@ private: | |||
| std::vector<HmiControl> controls; | |||
| 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< | |||
| std::monostate, | |||
| HmiClipboardData, | |||
| LogicNodesClipboardData, | |||
| LogicOutputClipboardData, | |||
| LogicRungClipboardData>; | |||
| LogicClipboardData>; | |||
| EditorClipboard editor_clipboard_; | |||
| }; | |||
| @@ -261,14 +261,20 @@ | |||
| <addaction name="pasteAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="deleteSelectionAction"/> | |||
| <addaction name="insertRungAboveAction"/> | |||
| <addaction name="insertRungBelowAction"/> | |||
| <addaction name="deleteRungAction"/> | |||
| <addaction name="deleteHorizontalWireAction"/> | |||
| <addaction name="deleteVerticalWireAction"/> | |||
| <addaction name="clearSelectionAction"/> | |||
| </widget> | |||
| <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="offlineModeAction"/> | |||
| <addaction name="onlineModeAction"/> | |||
| @@ -344,6 +350,12 @@ | |||
| <bool>true</bool> | |||
| </attribute> | |||
| <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="insertVerticalWireAction"/> | |||
| <addaction name="parallelInsertAction"/> | |||
| @@ -353,6 +365,8 @@ | |||
| <addaction name="addNormallyClosedAction"/> | |||
| <addaction name="addNormalCoilAction"/> | |||
| <addaction name="editRungCommentAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="syntaxCheckAction"/> | |||
| </widget> | |||
| <widget class="QDockWidget" name="projectDock"> | |||
| <property name="minimumSize"> | |||
| @@ -1139,12 +1153,47 @@ | |||
| <string>在梯形图末尾新增网络</string> | |||
| </property> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>横线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string> | |||
| <string>在当前条件格插入横线并自动右移;空逻辑会先创建首行</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F11</string> | |||
| @@ -1155,7 +1204,7 @@ | |||
| <string>竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>为所选连续逻辑范围建立横线旁路和竖线连接</string> | |||
| <string>在当前行与下一行的选中列边界建立一段竖线连接</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F12</string> | |||
| @@ -1177,7 +1226,7 @@ | |||
| <string>删除竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>删除画布中选中的竖线及其对应并联支路</string> | |||
| <string>删除画布中选中的竖线连接,让网络在此处拆分</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Shift+F12</string> | |||
| @@ -1196,7 +1245,7 @@ | |||
| <string>常开</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string> | |||
| <string>在当前条件格添加常开触点并自动右移;空逻辑会先创建首行</string> | |||
| </property> | |||
| </action> | |||
| <action name="addNormallyClosedAction"> | |||
| @@ -1204,7 +1253,7 @@ | |||
| <string>常闭</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string> | |||
| <string>在当前条件格添加常闭触点并自动右移;空逻辑会先创建首行</string> | |||
| </property> | |||
| </action> | |||
| <action name="addRisingEdgeAction"> | |||
| @@ -1277,10 +1326,29 @@ | |||
| </action> | |||
| <action name="deleteLogicAction"> | |||
| <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 name="toolTip"> | |||
| <string>删除选中的逻辑节点或网络</string> | |||
| <string>检查当前梯形图中的重复 M 线圈输出</string> | |||
| </property> | |||
| </action> | |||
| <action name="editingModeAction"> | |||
| @@ -231,8 +231,7 @@ void RuntimeMonitorWidget::setLogicTrace( | |||
| latest_trace_ = trace; | |||
| latest_fault_node_id_ = fault_node_id; | |||
| 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 | |||
| @@ -265,7 +264,6 @@ void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id) | |||
| logic_view_->reloadLogic(); | |||
| 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); | |||
| } | |||
| logic_editor_widget_.setRuntimeTrace( | |||
| runtime_mode_service_.offlineSimulationService() | |||
| .traceSnapshot().forLogic(logic_id), | |||
| runtime_mode_service_.offlineSimulationService().traceSnapshot(), | |||
| error.nodeId); | |||
| if (runtime_monitor_widget_ != nullptr) | |||
| { | |||
| @@ -393,7 +392,7 @@ void RuntimePanelController::handleScanCompleted() | |||
| const LogicTraceSnapshot &trace = offline_running | |||
| ? runtime_mode_service_.offlineSimulationService().traceSnapshot() | |||
| : runtime_mode_service_.onlineLogicMonitorService().traceSnapshot(); | |||
| logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id)); | |||
| logic_editor_widget_.setRuntimeTrace(trace); | |||
| if (runtime_monitor_widget_ != nullptr) | |||
| { | |||
| runtime_monitor_widget_->setLogicTrace(trace); | |||
| @@ -351,6 +351,23 @@ QPixmap renderIcon(UiIcon icon, int size) | |||
| painter.drawLine(QPointF(7, 12), QPointF(17, 12)); | |||
| 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: | |||
| { | |||
| 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); | |||
| 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: | |||
| { | |||
| painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5); | |||
| @@ -36,6 +36,8 @@ enum class UiIcon | |||
| AlarmSettings, // 报警配置 | |||
| Delete, // 删除 | |||
| AddRung, // 新增网络 | |||
| MouseDrawWire, // 鼠标画线模式 | |||
| MouseEraseWire, // 鼠标删线模式 | |||
| HorizontalWire, // 横线 | |||
| VerticalWire, // 竖线 | |||
| ParallelBranch, // 并联支路 | |||
| @@ -51,6 +53,7 @@ enum class UiIcon | |||
| Subtract, // SUB 指令 | |||
| Compare, // 比较指令 | |||
| Comment, // 注释 | |||
| SyntaxCheck, // 梯形图语法检查与规整 | |||
| More, // 更多操作 | |||
| HmiPage, // HMI 页面 | |||
| Logic, // 控制逻辑 | |||
| @@ -13,7 +13,6 @@ | |||
| #include <cmath> | |||
| #include <cstdint> | |||
| #include <exception> | |||
| #include <functional> | |||
| #include <iostream> | |||
| #include <limits> | |||
| #include <set> | |||
| @@ -24,30 +23,6 @@ namespace { | |||
| 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() | |||
| { | |||
| // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | |||
| @@ -336,18 +311,20 @@ Project makeValidProject() | |||
| LadderRung rung; | |||
| rung.id = "rung-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; | |||
| logic.rungs.push_back(rung); | |||
| Project project; | |||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | |||
| project.metadata = {"sample-project", "Sample project", "2.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.initialHmiPageId = page.id; | |||
| project.controlLogics.push_back(logic); | |||
| @@ -402,8 +379,10 @@ void testMultiPageAndLogicDomainRules() | |||
| disabled_draft.id = "draft-logic"; | |||
| disabled_draft.name = "Draft logic"; | |||
| 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); | |||
| require(project.validateForRunning(), | |||
| "a disabled draft logic must not block offline running"); | |||
| @@ -521,60 +500,10 @@ void testQuantityBoundaries() | |||
| project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; | |||
| 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() | |||
| @@ -674,151 +603,106 @@ void testDataInstructionBoundaries() | |||
| 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; | |||
| 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(); | |||
| 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() | |||
| @@ -101,58 +101,21 @@ LadderRung rung(const std::string &id, | |||
| LadderRung result; | |||
| result.id = 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; | |||
| return result; | |||
| @@ -191,58 +154,42 @@ std::int16_t readWord(RegisterRepository &repository, int address) | |||
| return result.value; | |||
| } | |||
| void testNestedSeriesParallelExpression() | |||
| void testParallelRowsAndColumnPropagation() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| 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; | |||
| 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, | |||
| "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, | |||
| "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() | |||
| @@ -252,8 +199,15 @@ void testUnconditionalCoil() | |||
| LadderRung unconditional; | |||
| unconditional.id = "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); | |||
| const ControlLogic program = logic({unconditional}); | |||
| @@ -264,27 +218,52 @@ void testUnconditionalCoil() | |||
| "a full-width wire network scan must succeed"); | |||
| require(readBit(repository, 10), | |||
| "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"); | |||
| } | |||
| 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() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| 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; | |||
| wired_rung.id = "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); | |||
| const ControlLogic program = logic({wired_rung}); | |||
| @@ -294,17 +273,17 @@ void testWirePassThroughAndPowerTrace() | |||
| "wire expression scan must succeed"); | |||
| require(!readBit(repository, 10), | |||
| "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"); | |||
| writeBit(repository, 0, true); | |||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||
| "powered wire expression scan must succeed"); | |||
| 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"); | |||
| } | |||
| @@ -312,12 +291,20 @@ void testSeriesParallelContactsAndSequentialVisibility() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| 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); | |||
| require(executor.executeScan({program}, repository).succeeded, | |||
| @@ -333,6 +320,106 @@ void testSeriesParallelContactsAndSequentialVisibility() | |||
| 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() | |||
| { | |||
| const std::array<ComparisonOperator, 6> operations{ | |||
| @@ -412,8 +499,10 @@ void testMultipleLogicScanOrderAndTraceIsolation() | |||
| disabled_draft.id = "logic-draft"; | |||
| disabled_draft.name = "Draft"; | |||
| 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, | |||
| "a disabled incomplete logic module must not block offline execution"); | |||
| } | |||
| @@ -614,11 +703,22 @@ void testHmiSimulationClosedLoop() | |||
| indicator.bounds = {0, 40, 80, 30}; | |||
| indicator.text = "run"; | |||
| 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, | |||
| "HMI start button press must write virtual M"); | |||
| @@ -779,9 +879,11 @@ int main(int argc, char *argv[]) | |||
| try | |||
| { | |||
| testSeriesParallelContactsAndSequentialVisibility(); | |||
| testNestedSeriesParallelExpression(); | |||
| testParallelRowsAndColumnPropagation(); | |||
| testUnconditionalCoil(); | |||
| testEnabledEmptyRowIsRejectedBeforeScanning(); | |||
| testWirePassThroughAndPowerTrace(); | |||
| testMotorForwardReverseSelfHoldAndInterlockTruthTable(); | |||
| testAllComparisons(); | |||
| testSetResetAndDisabledLogic(); | |||
| testMultipleLogicScanOrderAndTraceIsolation(); | |||
| @@ -36,20 +36,24 @@ ControlLogic makeLogic(int index) | |||
| LadderRung rung; | |||
| rung.id = "rung-" + std::to_string(index); | |||
| 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); | |||
| return {"logic-" + std::to_string(index), | |||
| "logic-" + std::to_string(index), | |||
| {rung}, | |||
| true}; | |||
| true, | |||
| {}}; | |||
| } | |||
| class PerformanceTests final : public QObject | |||
| @@ -11,6 +11,7 @@ | |||
| #include "services/plc_discovery_gateway.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include <functional> | |||
| #include <iostream> | |||
| @@ -187,15 +188,20 @@ void testRuntimeRepositorySwitchingAndDisconnect() | |||
| indicator.text = "Run"; | |||
| indicator.binding = RegisterAddress{RegisterArea::M, 5}; | |||
| page.controls.push_back(indicator); | |||
| project_service.editProject().initialHmiPageId = page.id; | |||
| project_service.editProject().hmiPages.push_back(page); | |||
| VirtualRegisterRepository virtual_repository; | |||
| PlcRegisterRepository plc_repository; | |||
| ActiveRegisterRepository active_repository(virtual_repository); | |||
| OfflineSimulationService simulation_service(virtual_repository); | |||
| OnlineLogicMonitorService online_monitor_service(plc_repository); | |||
| LogicEditorService logic_editor_service(project_service); | |||
| FakePlcGateway gateway; | |||
| RuntimeModeService service( | |||
| project_service, simulation_service, online_monitor_service); | |||
| project_service, | |||
| logic_editor_service, | |||
| simulation_service, | |||
| online_monitor_service); | |||
| service.configurePlc( | |||
| gateway, active_repository, virtual_repository, plc_repository); | |||
| @@ -246,9 +252,13 @@ void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect() | |||
| ActiveRegisterRepository active_repository(virtual_repository); | |||
| OfflineSimulationService simulation_service(virtual_repository); | |||
| OnlineLogicMonitorService online_monitor_service(plc_repository); | |||
| LogicEditorService logic_editor_service(project_service); | |||
| FakePlcGateway gateway; | |||
| RuntimeModeService service( | |||
| project_service, simulation_service, online_monitor_service); | |||
| project_service, | |||
| logic_editor_service, | |||
| simulation_service, | |||
| online_monitor_service); | |||
| service.configurePlc( | |||
| gateway, active_repository, virtual_repository, plc_repository); | |||
| @@ -10,6 +10,7 @@ SOURCES += \ | |||
| plc_runtime_tests.cpp \ | |||
| $$DOMAIN_ALL_SOURCES \ | |||
| $$SERVICE_PROJECT_SOURCES \ | |||
| $$SERVICE_LOGIC_SOURCES \ | |||
| $$SERVICE_OFFLINE_SOURCES \ | |||
| $$SERVICE_RUNTIME_SOURCES \ | |||
| $$INFRASTRUCTURE_PLC_SOURCES | |||
| @@ -17,6 +18,7 @@ SOURCES += \ | |||
| HEADERS += \ | |||
| $$DOMAIN_ALL_HEADERS \ | |||
| $$SERVICE_PROJECT_HEADERS \ | |||
| $$SERVICE_LOGIC_HEADERS \ | |||
| $$SERVICE_OFFLINE_HEADERS \ | |||
| $$SERVICE_RUNTIME_HEADERS \ | |||
| $$INFRASTRUCTURE_PLC_HEADERS \ | |||
| @@ -1,4 +1,5 @@ | |||
| #include "services/runtime_mode_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/project_service.h" | |||
| #include "domain/active_register_repository.h" | |||
| @@ -100,17 +101,17 @@ private: | |||
| 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() | |||
| @@ -123,9 +124,13 @@ void testModeTransitions() | |||
| 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, simulation_service, online_monitor_service); | |||
| project_service, | |||
| logic_editor_service, | |||
| simulation_service, | |||
| online_monitor_service); | |||
| service.configurePlc( | |||
| gateway, active_repository, virtual_repository, plc_repository); | |||
| @@ -152,8 +157,7 @@ void testModeTransitions() | |||
| LadderRung edge_rung; | |||
| edge_rung.id = "poll-edge-rung"; | |||
| edge_rung.name = "Poll edge"; | |||
| edge_rung.condition = conditionWithOutputWire( | |||
| edge, "poll-edge-output-wire"); | |||
| setConditionPath(&edge_rung, edge); | |||
| edge_rung.output = edge_coil; | |||
| logic.rungs.push_back(edge_rung); | |||
| LogicNode comparison; | |||
| @@ -167,8 +171,7 @@ void testModeTransitions() | |||
| LadderRung comparison_rung; | |||
| comparison_rung.id = "poll-comparison-rung"; | |||
| comparison_rung.name = "Poll comparison"; | |||
| comparison_rung.condition = conditionWithOutputWire( | |||
| comparison, "poll-comparison-output-wire"); | |||
| setConditionPath(&comparison_rung, comparison); | |||
| comparison_rung.output = comparison_coil; | |||
| logic.rungs.push_back(comparison_rung); | |||
| LogicNode move_input; | |||
| @@ -186,8 +189,7 @@ void testModeTransitions() | |||
| LadderRung move_rung; | |||
| move_rung.id = "poll-move-rung"; | |||
| 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; | |||
| logic.rungs.push_back(move_rung); | |||
| LogicNode add_input; | |||
| @@ -210,8 +212,7 @@ void testModeTransitions() | |||
| LadderRung add_rung; | |||
| add_rung.id = "poll-add-rung"; | |||
| 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; | |||
| logic.rungs.push_back(add_rung); | |||
| project.controlLogics.push_back(logic); | |||
| @@ -276,6 +277,100 @@ void testModeTransitions() | |||
| "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 | |||
| int main() | |||
| @@ -284,6 +379,7 @@ int main() | |||
| { | |||
| // 运行模式只有这一组状态机边界测试 | |||
| testModeTransitions(); | |||
| testDisconnectedOutputBlocksOfflineAndOnlineRuntime(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| @@ -8,12 +8,14 @@ SOURCES += \ | |||
| runtime_mode_service_tests.cpp \ | |||
| $$DOMAIN_ALL_SOURCES \ | |||
| $$SERVICE_PROJECT_SOURCES \ | |||
| $$SERVICE_LOGIC_SOURCES \ | |||
| $$SERVICE_OFFLINE_SOURCES \ | |||
| $$SERVICE_RUNTIME_SOURCES | |||
| HEADERS += \ | |||
| $$DOMAIN_ALL_HEADERS \ | |||
| $$SERVICE_PROJECT_HEADERS \ | |||
| $$SERVICE_LOGIC_HEADERS \ | |||
| $$SERVICE_OFFLINE_HEADERS \ | |||
| $$SERVICE_RUNTIME_HEADERS \ | |||
| $$TEST_SUPPORT_HEADERS | |||
| @@ -17,9 +17,18 @@ | |||
| #include <QApplication> | |||
| #include <QCoreApplication> | |||
| #include <QEventLoop> | |||
| #include <QGraphicsLineItem> | |||
| #include <QGraphicsScene> | |||
| #include <QGraphicsSimpleTextItem> | |||
| #include <QImage> | |||
| #include <QKeyEvent> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <QMouseEvent> | |||
| #include <QPainter> | |||
| #include <QWidget> | |||
| #include <algorithm> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| @@ -38,7 +47,15 @@ ControlLogic makeAlwaysOnLogic() | |||
| LadderRung rung; | |||
| rung.id = "always-on-rung"; | |||
| 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; | |||
| output.id = "always-on-output"; | |||
| @@ -56,11 +73,14 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||
| VirtualRegisterRepository virtual_repository; | |||
| OfflineSimulationService simulation_service(virtual_repository); | |||
| OnlineLogicMonitorService online_monitor_service(virtual_repository); | |||
| LogicEditorService logic_editor_service(project_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); | |||
| HmiRuntimeService hmi_runtime_service(virtual_repository); | |||
| LogicEditorService logic_editor_service(project_service); | |||
| HmiNavigationService hmi_navigation_service(project_service); | |||
| AlarmService alarm_service(project_service, virtual_repository); | |||
| RegisterMonitorService register_monitor_service(virtual_repository); | |||
| @@ -128,6 +148,27 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||
| require(simulation_service.traceSnapshot() | |||
| .forLogic(logic.id).rungValues.at("always-on-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, | |||
| "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"); | |||
| } | |||
| 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 | |||
| int main(int argc, char *argv[]) | |||
| @@ -151,6 +857,11 @@ int main(int argc, char *argv[]) | |||
| try | |||
| { | |||
| testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); | |||
| testLogicEditorGridSelectionAndDeletion(); | |||
| testLadderLayoutAndDragDeletion(); | |||
| testCursorAdvanceAndInlineCommandInput(); | |||
| testSegmentLevelTraceProjection(); | |||
| testLogicClipboardUsesExplicitObjectAndRowSelection(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||