| @@ -1,12 +1,9 @@ | |||
| #include "control_logic_model.h" | |||
| #include <algorithm> | |||
| #include <unordered_map> | |||
| #include <unordered_set> | |||
| namespace { | |||
| // 在提供错误字符串时写入校验失败原因 | |||
| void setError(std::string *error, const std::string &message) | |||
| { | |||
| if (error != nullptr) | |||
| @@ -15,30 +12,6 @@ void setError(std::string *error, const std::string &message) | |||
| } | |||
| } | |||
| // 判断触点工作方式是否受支持 | |||
| bool isSupportedMode(ContactMode mode) | |||
| { | |||
| return mode == ContactMode::NormallyOpen || mode == ContactMode::NormallyClosed; | |||
| } | |||
| // 判断线圈工作方式是否受支持 | |||
| bool isSupportedMode(CoilMode mode) | |||
| { | |||
| return mode == CoilMode::Normal || mode == CoilMode::Set || mode == CoilMode::Reset; | |||
| } | |||
| // 判断数值比较运算符是否受支持 | |||
| bool isSupportedComparison(ComparisonOperator comparison) | |||
| { | |||
| return comparison == ComparisonOperator::Equal | |||
| || comparison == ComparisonOperator::NotEqual | |||
| || comparison == ComparisonOperator::LessThan | |||
| || comparison == ComparisonOperator::LessThanOrEqual | |||
| || comparison == ComparisonOperator::GreaterThan | |||
| || comparison == ComparisonOperator::GreaterThanOrEqual; | |||
| } | |||
| // 校验触点节点配置 | |||
| bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| @@ -46,7 +19,8 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||
| setError(error, "contact node requires a valid M address"); | |||
| return false; | |||
| } | |||
| if (!isSupportedMode(config.mode)) | |||
| if (config.mode != ContactMode::NormallyOpen | |||
| && config.mode != ContactMode::NormallyClosed) | |||
| { | |||
| setError(error, "contact node has an unsupported mode"); | |||
| return false; | |||
| @@ -54,7 +28,6 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||
| return true; | |||
| } | |||
| // 校验线圈节点配置 | |||
| bool validateConfig(const CoilNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| @@ -62,7 +35,9 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error) | |||
| setError(error, "coil node requires a valid M address"); | |||
| return false; | |||
| } | |||
| if (!isSupportedMode(config.mode)) | |||
| if (config.mode != CoilMode::Normal | |||
| && config.mode != CoilMode::Set | |||
| && config.mode != CoilMode::Reset) | |||
| { | |||
| setError(error, "coil node has an unsupported mode"); | |||
| return false; | |||
| @@ -70,7 +45,6 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error) | |||
| return true; | |||
| } | |||
| // 校验数值比较节点配置 | |||
| bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::D) | |||
| @@ -78,7 +52,12 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| setError(error, "comparison node requires a valid D address"); | |||
| return false; | |||
| } | |||
| if (!isSupportedComparison(config.comparison)) | |||
| if (config.comparison != ComparisonOperator::Equal | |||
| && config.comparison != ComparisonOperator::NotEqual | |||
| && config.comparison != ComparisonOperator::LessThan | |||
| && config.comparison != ComparisonOperator::LessThanOrEqual | |||
| && config.comparison != ComparisonOperator::GreaterThan | |||
| && config.comparison != ComparisonOperator::GreaterThanOrEqual) | |||
| { | |||
| setError(error, "comparison node has an unsupported operator"); | |||
| return false; | |||
| @@ -86,14 +65,27 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| return true; | |||
| } | |||
| } // namespace | |||
| bool LogicPoint::isValid() const | |||
| template<typename TItem> | |||
| bool hasDuplicateId(const std::vector<TItem> &items) | |||
| { | |||
| return x >= kMinimumCoordinate && x <= kMaximumCoordinate | |||
| && y >= kMinimumCoordinate && y <= kMaximumCoordinate; | |||
| for (auto current = items.cbegin(); current != items.cend(); ++current) | |||
| { | |||
| if (std::find_if( | |||
| current + 1, | |||
| items.cend(), | |||
| [¤t](const TItem &candidate) | |||
| { | |||
| return candidate.id == current->id; | |||
| }) != items.cend()) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| } // namespace | |||
| bool LogicNode::validate(std::string *error) const | |||
| { | |||
| if (id.empty()) | |||
| @@ -101,220 +93,166 @@ bool LogicNode::validate(std::string *error) const | |||
| setError(error, "logic node id must not be empty"); | |||
| return false; | |||
| } | |||
| if (!position.isValid()) | |||
| { | |||
| setError(error, "logic node position is outside the supported range"); | |||
| return false; | |||
| } | |||
| // 根据 variant 中实际保存的配置类型调用对应校验函数 | |||
| return std::visit( | |||
| [error](const auto &nodeConfig) | |||
| [error](const auto &config) | |||
| { | |||
| return validateConfig(nodeConfig, error); | |||
| return validateConfig(config, error); | |||
| }, | |||
| config); | |||
| } | |||
| bool LogicConnection::validate(std::string *error) const | |||
| bool LogicNode::isCondition() const | |||
| { | |||
| // 连接两端必须存在且不能指向同一个节点 | |||
| if (fromNodeId.empty() || toNodeId.empty() || fromNodeId == toNodeId) | |||
| { | |||
| setError(error, "logic connection must connect two different nodes"); | |||
| return false; | |||
| } | |||
| return true; | |||
| return !std::holds_alternative<CoilNodeConfig>(config); | |||
| } | |||
| bool ControlLogic::validate(std::string *error) const | |||
| bool LogicNode::isOutput() const | |||
| { | |||
| if (!validateStructure(error)) | |||
| return std::holds_alternative<CoilNodeConfig>(config); | |||
| } | |||
| bool LadderStage::validate(std::string *error) const | |||
| { | |||
| if (id.empty() || branches.empty()) | |||
| { | |||
| setError(error, "ladder stage id and branches must not be empty"); | |||
| return false; | |||
| } | |||
| if (id.empty() || name.empty()) | |||
| if (hasDuplicateId(branches)) | |||
| { | |||
| setError(error, "control logic id and name must not be empty"); | |||
| setError(error, "parallel branch node ids must be unique"); | |||
| return false; | |||
| } | |||
| if (nodes.empty()) | |||
| { | |||
| return true; | |||
| } | |||
| std::unordered_map<std::string, std::size_t> incoming_count; | |||
| for (const LogicNode &node : nodes) | |||
| { | |||
| incoming_count.emplace(node.id, 0U); | |||
| } | |||
| for (const LogicConnection &connection : connections) | |||
| { | |||
| ++incoming_count.at(connection.toNodeId); | |||
| } | |||
| // 线圈必须由至少一个条件路径驱动,避免孤立线圈在执行时意外写入 M 位 | |||
| for (const LogicNode &node : nodes) | |||
| for (const LogicNode &node : branches) | |||
| { | |||
| if (std::holds_alternative<CoilNodeConfig>(node.config) | |||
| && incoming_count.at(node.id) == 0U) | |||
| if (!node.validate(error)) | |||
| { | |||
| setError(error, "coil node must have at least one input connection"); | |||
| return false; | |||
| } | |||
| } | |||
| // 从所有线圈沿反向连接遍历,一次找出能够产生动作的全部条件节点 | |||
| std::unordered_map<std::string, std::vector<std::string>> incoming; | |||
| std::vector<std::string> pending; | |||
| for (const LogicNode &node : nodes) | |||
| { | |||
| incoming.emplace(node.id, std::vector<std::string>{}); | |||
| if (std::holds_alternative<CoilNodeConfig>(node.config)) | |||
| if (!node.isCondition()) | |||
| { | |||
| pending.push_back(node.id); | |||
| setError(error, "ladder stage may contain condition nodes only"); | |||
| return false; | |||
| } | |||
| } | |||
| for (const LogicConnection &connection : connections) | |||
| return true; | |||
| } | |||
| bool LadderRung::validate(std::string *error) const | |||
| { | |||
| if (!validateStructure(error)) | |||
| { | |||
| incoming.at(connection.toNodeId).push_back(connection.fromNodeId); | |||
| return false; | |||
| } | |||
| std::unordered_set<std::string> reaches_coil; | |||
| while (!pending.empty()) | |||
| if (stages.empty() && !output.has_value()) | |||
| { | |||
| const std::string current = pending.back(); | |||
| pending.pop_back(); | |||
| if (!reaches_coil.insert(current).second) | |||
| { | |||
| continue; | |||
| } | |||
| for (const std::string &previous : incoming.at(current)) | |||
| { | |||
| pending.push_back(previous); | |||
| } | |||
| return true; | |||
| } | |||
| if (reaches_coil.size() != nodes.size()) | |||
| if (stages.empty() || !output.has_value()) | |||
| { | |||
| setError(error, "condition node must reach a coil node"); | |||
| setError(error, "non-empty ladder rung requires conditions and an output coil"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool ControlLogic::validateStructure(std::string *error) const | |||
| bool LadderRung::validateStructure(std::string *error) const | |||
| { | |||
| if (id.empty() || name.empty()) | |||
| { | |||
| setError(error, "control logic id and name must not be empty"); | |||
| setError(error, "ladder rung id and name must not be empty"); | |||
| return false; | |||
| } | |||
| if (hasDuplicateId(stages)) | |||
| { | |||
| setError(error, "ladder stage ids must be unique within a rung"); | |||
| return false; | |||
| } | |||
| // 校验所有节点并收集节点标识用于后续检查连接引用 | |||
| std::vector<std::string> node_ids; | |||
| node_ids.reserve(nodes.size()); | |||
| for (const LogicNode &node : nodes) | |||
| for (const LadderStage &stage : stages) | |||
| { | |||
| if (!node.validate(error)) | |||
| if (!stage.validate(error)) | |||
| { | |||
| return false; | |||
| } | |||
| if (std::find(node_ids.cbegin(), node_ids.cend(), node.id) != node_ids.cend()) | |||
| for (const LogicNode &node : stage.branches) | |||
| { | |||
| setError(error, "logic node ids must be unique within a logic"); | |||
| return false; | |||
| node_ids.push_back(node.id); | |||
| } | |||
| node_ids.push_back(node.id); | |||
| } | |||
| // 每条连接只能引用当前控制逻辑中已经定义的节点 | |||
| for (const LogicConnection &connection : connections) | |||
| if (output.has_value()) | |||
| { | |||
| if (!connection.validate(error)) | |||
| if (!output->validate(error)) | |||
| { | |||
| return false; | |||
| } | |||
| if (std::find(node_ids.cbegin(), node_ids.cend(), connection.fromNodeId) | |||
| == node_ids.cend() | |||
| || std::find(node_ids.cbegin(), node_ids.cend(), connection.toNodeId) | |||
| == node_ids.cend()) | |||
| if (!output->isOutput()) | |||
| { | |||
| setError(error, "logic connection references an unknown node"); | |||
| setError(error, "ladder rung output must be a coil node"); | |||
| return false; | |||
| } | |||
| node_ids.push_back(output->id); | |||
| } | |||
| // 同一对节点只能存在一条有向连接,避免执行语义和删除行为产生歧义 | |||
| for (auto current = connections.cbegin(); current != connections.cend(); ++current) | |||
| std::sort(node_ids.begin(), node_ids.end()); | |||
| if (std::adjacent_find(node_ids.cbegin(), node_ids.cend()) != node_ids.cend()) | |||
| { | |||
| const auto duplicate = std::find_if( | |||
| current + 1, | |||
| connections.cend(), | |||
| [current](const LogicConnection &candidate) | |||
| { | |||
| return candidate.fromNodeId == current->fromNodeId | |||
| && candidate.toNodeId == current->toNodeId; | |||
| }); | |||
| if (duplicate != connections.cend()) | |||
| { | |||
| setError(error, "logic connections must be unique"); | |||
| return false; | |||
| } | |||
| setError(error, "logic node ids must be unique within a rung"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| std::unordered_map<std::string, std::vector<std::string>> outgoing; | |||
| std::unordered_map<std::string, std::size_t> incoming_count; | |||
| for (const LogicNode &node : nodes) | |||
| bool ControlLogic::validate(std::string *error) const | |||
| { | |||
| if (!validateStructure(error)) | |||
| { | |||
| outgoing.emplace(node.id, std::vector<std::string>{}); | |||
| incoming_count.emplace(node.id, 0U); | |||
| return false; | |||
| } | |||
| for (const LogicConnection &connection : connections) | |||
| for (const LadderRung &rung : rungs) | |||
| { | |||
| const auto from = std::find_if( | |||
| nodes.cbegin(), | |||
| nodes.cend(), | |||
| [&connection](const LogicNode &node) | |||
| { | |||
| return node.id == connection.fromNodeId; | |||
| }); | |||
| if (std::holds_alternative<CoilNodeConfig>(from->config)) | |||
| if (!rung.validate(error)) | |||
| { | |||
| setError(error, "coil node cannot have outgoing connections"); | |||
| return false; | |||
| } | |||
| outgoing.at(connection.fromNodeId).push_back(connection.toNodeId); | |||
| ++incoming_count.at(connection.toNodeId); | |||
| } | |||
| return true; | |||
| } | |||
| // 使用迭代拓扑排序校验环路,不改变节点的持久化顺序 | |||
| std::vector<std::string> pending; | |||
| for (const auto &entry : incoming_count) | |||
| bool ControlLogic::validateStructure(std::string *error) const | |||
| { | |||
| if (id.empty() || name.empty()) | |||
| { | |||
| if (entry.second == 0U) | |||
| { | |||
| pending.push_back(entry.first); | |||
| } | |||
| setError(error, "control logic id and name must not be empty"); | |||
| return false; | |||
| } | |||
| if (hasDuplicateId(rungs)) | |||
| { | |||
| setError(error, "ladder rung ids must be unique within a logic"); | |||
| return false; | |||
| } | |||
| std::size_t visited_count = 0U; | |||
| while (!pending.empty()) | |||
| std::vector<std::string> node_ids; | |||
| for (const LadderRung &rung : rungs) | |||
| { | |||
| const std::string current = pending.back(); | |||
| pending.pop_back(); | |||
| ++visited_count; | |||
| for (const std::string &next : outgoing.at(current)) | |||
| if (!rung.validateStructure(error)) | |||
| { | |||
| return false; | |||
| } | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| std::size_t &count = incoming_count.at(next); | |||
| --count; | |||
| if (count == 0U) | |||
| for (const LogicNode &node : stage.branches) | |||
| { | |||
| pending.push_back(next); | |||
| node_ids.push_back(node.id); | |||
| } | |||
| } | |||
| if (rung.output.has_value()) | |||
| { | |||
| node_ids.push_back(rung.output->id); | |||
| } | |||
| } | |||
| if (visited_count != nodes.size()) | |||
| std::sort(node_ids.begin(), node_ids.end()); | |||
| if (std::adjacent_find(node_ids.cbegin(), node_ids.cend()) != node_ids.cend()) | |||
| { | |||
| setError(error, "logic connections must not contain a cycle"); | |||
| setError(error, "logic node ids must be unique within a logic"); | |||
| return false; | |||
| } | |||
| return true; | |||
| @@ -3,122 +3,93 @@ | |||
| #include "register_address.h" | |||
| #include <cstdint> | |||
| #include <optional> | |||
| #include <string> | |||
| #include <variant> | |||
| #include <vector> | |||
| // 逻辑节点在编辑画布中的持久化坐标 | |||
| struct LogicPoint | |||
| { | |||
| static constexpr int kMinimumCoordinate = 0; | |||
| static constexpr int kMaximumCoordinate = 100000; | |||
| int x = 0; | |||
| int y = 0; | |||
| bool isValid() const; | |||
| }; | |||
| // 触点工作方式 | |||
| enum class ContactMode | |||
| { | |||
| NormallyOpen, // 常开触点 | |||
| NormallyClosed // 常闭触点 | |||
| NormallyOpen, | |||
| NormallyClosed | |||
| }; | |||
| // 线圈工作方式 | |||
| enum class CoilMode | |||
| { | |||
| Normal, // 普通线圈 | |||
| Set, // 置位线圈 | |||
| Reset // 复位线圈 | |||
| Normal, | |||
| Set, | |||
| Reset | |||
| }; | |||
| // 数值比较运算符 | |||
| enum class ComparisonOperator | |||
| { | |||
| Equal, // 等于 | |||
| NotEqual, // 不等于 | |||
| LessThan, // 小于 | |||
| LessThanOrEqual, // 小于等于 | |||
| GreaterThan, // 大于 | |||
| GreaterThanOrEqual // 大于等于 | |||
| Equal, | |||
| NotEqual, | |||
| LessThan, | |||
| LessThanOrEqual, | |||
| GreaterThan, | |||
| GreaterThanOrEqual | |||
| }; | |||
| // 描述读取 M 区位状态的触点节点配置 | |||
| struct ContactNodeConfig | |||
| { | |||
| // 触点绑定的 M 区地址 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| // 触点工作方式 | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| }; | |||
| // 描述向 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; | |||
| }; | |||
| // 控制逻辑节点支持的配置类型 | |||
| using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | |||
| // 描述控制逻辑中的一个功能节点 | |||
| struct LogicNode | |||
| { | |||
| // 节点唯一标识 | |||
| std::string id; | |||
| // 节点自身的业务配置 | |||
| LogicNodeConfig config; | |||
| // 节点在逻辑编辑画布中的位置 | |||
| LogicPoint position; | |||
| // 校验节点配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool isCondition() const; | |||
| bool isOutput() const; | |||
| }; | |||
| // 描述两个控制逻辑节点之间的连接关系 | |||
| struct LogicConnection | |||
| // 同一个串联级内的条件互为并联关系,任一条件成立即通过该级 | |||
| struct LadderStage | |||
| { | |||
| // 连接起点的节点标识 | |||
| std::string fromNodeId; | |||
| // 连接终点的节点标识 | |||
| std::string toNodeId; | |||
| std::string id; | |||
| std::vector<LogicNode> branches; | |||
| // 校验连接配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 描述一组完整的控制逻辑及其节点和连接 | |||
| // 梯级中的各级按顺序串联,输出线圈固定在最右侧 | |||
| struct LadderRung | |||
| { | |||
| std::string id; | |||
| std::string name; | |||
| std::vector<LadderStage> stages; | |||
| std::optional<LogicNode> output; | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validateStructure(std::string *error = nullptr) const; | |||
| }; | |||
| struct ControlLogic | |||
| { | |||
| // 控制逻辑唯一标识 | |||
| std::string id; | |||
| // 控制逻辑显示名称 | |||
| std::string name; | |||
| // 控制逻辑包含的节点集合 | |||
| std::vector<LogicNode> nodes; | |||
| // 控制逻辑包含的连接集合 | |||
| std::vector<LogicConnection> connections; | |||
| // 控制逻辑是否启用 | |||
| std::vector<LadderRung> rungs; | |||
| bool enabled = true; | |||
| // 校验控制逻辑并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| // 校验编辑过程所需的结构规则,允许暂时没有完整输出 | |||
| bool validateStructure(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -211,38 +211,6 @@ QJsonObject serializeAddress(const RegisterAddress &address) | |||
| return object; | |||
| } | |||
| QJsonObject serializeLogicPoint(const LogicPoint &position) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("x"), position.x); | |||
| object.insert(QStringLiteral("y"), position.y); | |||
| return object; | |||
| } | |||
| bool parseLogicPoint( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| LogicPoint *position, | |||
| ParseState *state) | |||
| { | |||
| return readInt( | |||
| object, | |||
| "x", | |||
| context, | |||
| LogicPoint::kMinimumCoordinate, | |||
| LogicPoint::kMaximumCoordinate, | |||
| &position->x, | |||
| state) | |||
| && readInt( | |||
| object, | |||
| "y", | |||
| context, | |||
| LogicPoint::kMinimumCoordinate, | |||
| LogicPoint::kMaximumCoordinate, | |||
| &position->y, | |||
| state); | |||
| } | |||
| // 解析寄存器地址并校验区域名称及项目允许的索引范围 | |||
| bool parseAddress( | |||
| const QJsonObject &object, | |||
| @@ -751,7 +719,6 @@ QJsonObject serializeLogicNode(const LogicNode &node) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(node.id)); | |||
| object.insert(QStringLiteral("position"), serializeLogicPoint(node.position)); | |||
| // std::visit 将不同节点配置统一转换为 config JSON 对象 | |||
| object.insert( | |||
| QStringLiteral("config"), | |||
| @@ -847,124 +814,184 @@ bool parseLogicNode( | |||
| ParseState *state) | |||
| { | |||
| QJsonObject config; | |||
| QJsonObject position; | |||
| if (!readString(object, "id", context, &node->id, state) | |||
| || !readObject(object, "config", context, &config, state) | |||
| || !readObject(object, "position", context, &position, state) | |||
| || !parseNodeConfig(config, context + ".config", &node->config, state)) | |||
| { | |||
| return false; | |||
| } | |||
| return parseLogicPoint( | |||
| position, | |||
| context + ".position", | |||
| &node->position, | |||
| state); | |||
| return true; | |||
| } | |||
| // 将逻辑节点之间的有向连接序列化为 JSON 对象 | |||
| QJsonObject serializeConnection(const LogicConnection &connection) | |||
| QJsonObject serializeLadderStage(const LadderStage &stage) | |||
| { | |||
| QJsonArray branches; | |||
| for (const LogicNode &node : stage.branches) | |||
| { | |||
| branches.append(serializeLogicNode(node)); | |||
| } | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("fromNodeId"), fromUtf8(connection.fromNodeId)); | |||
| object.insert(QStringLiteral("toNodeId"), fromUtf8(connection.toNodeId)); | |||
| object.insert(QStringLiteral("id"), fromUtf8(stage.id)); | |||
| object.insert(QStringLiteral("branches"), branches); | |||
| return object; | |||
| } | |||
| // 解析逻辑连接的起点和终点节点标识 | |||
| bool parseConnection( | |||
| bool parseLadderStage( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| LogicConnection *connection, | |||
| LadderStage *stage, | |||
| ParseState *state) | |||
| { | |||
| return readString(object, "fromNodeId", context, &connection->fromNodeId, state) | |||
| && readString(object, "toNodeId", context, &connection->toNodeId, state); | |||
| QJsonArray branches; | |||
| if (!readString(object, "id", context, &stage->id, state) | |||
| || !readArray(object, "branches", context, &branches, state)) | |||
| { | |||
| return false; | |||
| } | |||
| stage->branches.reserve(static_cast<std::size_t>(branches.size())); | |||
| for (int index = 0; index < branches.size(); ++index) | |||
| { | |||
| if (!branches.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".branches items must be objects"); | |||
| } | |||
| LogicNode node; | |||
| if (!parseLogicNode( | |||
| branches.at(index).toObject(), | |||
| context + ".branches[" + std::to_string(index) + ']', | |||
| &node, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| stage->branches.push_back(std::move(node)); | |||
| } | |||
| return true; | |||
| } | |||
| // 将一套控制逻辑及其节点和连接序列化为 JSON 对象 | |||
| QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| QJsonObject serializeLadderRung(const LadderRung &rung) | |||
| { | |||
| QJsonArray nodes; | |||
| for (const LogicNode &node : logic.nodes) | |||
| QJsonArray stages; | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| nodes.append(serializeLogicNode(node)); | |||
| stages.append(serializeLadderStage(stage)); | |||
| } | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | |||
| object.insert(QStringLiteral("stages"), stages); | |||
| object.insert( | |||
| QStringLiteral("output"), | |||
| rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output)) | |||
| : QJsonValue(QJsonValue::Null)); | |||
| return object; | |||
| } | |||
| QJsonArray connections; | |||
| for (const LogicConnection &connection : logic.connections) | |||
| bool parseLadderRung( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| LadderRung *rung, | |||
| ParseState *state) | |||
| { | |||
| QJsonArray stages; | |||
| QJsonValue output; | |||
| if (!readString(object, "id", context, &rung->id, state) | |||
| || !readString(object, "name", context, &rung->name, state) | |||
| || !readArray(object, "stages", context, &stages, state) | |||
| || !readValue(object, "output", context, &output, state)) | |||
| { | |||
| return false; | |||
| } | |||
| rung->stages.reserve(static_cast<std::size_t>(stages.size())); | |||
| for (int index = 0; index < stages.size(); ++index) | |||
| { | |||
| if (!stages.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".stages items must be objects"); | |||
| } | |||
| LadderStage stage; | |||
| if (!parseLadderStage( | |||
| stages.at(index).toObject(), | |||
| context + ".stages[" + std::to_string(index) + ']', | |||
| &stage, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| rung->stages.push_back(std::move(stage)); | |||
| } | |||
| if (output.isNull()) | |||
| { | |||
| rung->output.reset(); | |||
| return true; | |||
| } | |||
| if (!output.isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".output must be an object or null"); | |||
| } | |||
| LogicNode node; | |||
| if (!parseLogicNode(output.toObject(), context + ".output", &node, state)) | |||
| { | |||
| connections.append(serializeConnection(connection)); | |||
| return false; | |||
| } | |||
| rung->output = std::move(node); | |||
| return true; | |||
| } | |||
| QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| { | |||
| QJsonArray rungs; | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| rungs.append(serializeLadderRung(rung)); | |||
| } | |||
| 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("nodes"), nodes); | |||
| object.insert(QStringLiteral("connections"), connections); | |||
| object.insert(QStringLiteral("rungs"), rungs); | |||
| return object; | |||
| } | |||
| // 解析一套控制逻辑,并逐项构造节点和连接集合 | |||
| bool parseControlLogic( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| ControlLogic *logic, | |||
| ParseState *state) | |||
| { | |||
| QJsonArray nodes; | |||
| QJsonArray connections; | |||
| QJsonArray rungs; | |||
| if (!readString(object, "id", context, &logic->id, state) | |||
| || !readString(object, "name", context, &logic->name, state) | |||
| || !readBool(object, "enabled", context, &logic->enabled, state) | |||
| || !readArray(object, "nodes", context, &nodes, state) | |||
| || !readArray(object, "connections", context, &connections, state)) | |||
| || !readArray(object, "rungs", context, &rungs, state)) | |||
| { | |||
| return false; | |||
| } | |||
| // 数组元素必须是对象,错误上下文包含下标以便定位损坏数据 | |||
| logic->nodes.reserve(static_cast<std::size_t>(nodes.size())); | |||
| for (int index = 0; index < nodes.size(); ++index) | |||
| { | |||
| if (!nodes.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".nodes items must be objects"); | |||
| } | |||
| LogicNode node; | |||
| if (!parseLogicNode( | |||
| nodes.at(index).toObject(), | |||
| context + ".nodes[" + std::to_string(index) + ']', | |||
| &node, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| logic->nodes.push_back(std::move(node)); | |||
| } | |||
| logic->connections.reserve(static_cast<std::size_t>(connections.size())); | |||
| for (int index = 0; index < connections.size(); ++index) | |||
| logic->rungs.reserve(static_cast<std::size_t>(rungs.size())); | |||
| for (int index = 0; index < rungs.size(); ++index) | |||
| { | |||
| if (!connections.at(index).isObject()) | |||
| if (!rungs.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".connections items must be objects"); | |||
| context + ".rungs items must be objects"); | |||
| } | |||
| LogicConnection connection; | |||
| if (!parseConnection( | |||
| connections.at(index).toObject(), | |||
| context + ".connections[" + std::to_string(index) + ']', | |||
| &connection, | |||
| state)) | |||
| LadderRung rung; | |||
| if (!parseLadderRung( | |||
| rungs.at(index).toObject(), | |||
| context + ".rungs[" + std::to_string(index) + ']', | |||
| &rung, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| logic->connections.push_back(std::move(connection)); | |||
| logic->rungs.push_back(std::move(rung)); | |||
| } | |||
| return true; | |||
| } | |||
| @@ -9,17 +9,12 @@ | |||
| namespace { | |||
| bool validateCandidateNode( | |||
| const std::string &id, | |||
| const LogicNodeConfig &config, | |||
| const LogicPoint &position, | |||
| std::string *error) | |||
| LogicNode makeNode(const std::string &id, const LogicNodeConfig &config) | |||
| { | |||
| LogicNode candidate; | |||
| candidate.id = id; | |||
| candidate.config = config; | |||
| candidate.position = position; | |||
| return candidate.validate(error); | |||
| LogicNode node; | |||
| node.id = id; | |||
| node.config = config; | |||
| return node; | |||
| } | |||
| } // namespace | |||
| @@ -31,15 +26,33 @@ LogicEditorService::LogicEditorService(ProjectService &project_service) | |||
| const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| const auto &logics = project_service_.project().controlLogics; | |||
| const auto logic = std::find_if( | |||
| project.controlLogics.cbegin(), | |||
| project.controlLogics.cend(), | |||
| logics.cbegin(), | |||
| logics.cend(), | |||
| [&logic_id](const ControlLogic &candidate) | |||
| { | |||
| return candidate.id == logic_id; | |||
| }); | |||
| return logic == project.controlLogics.cend() ? nullptr : &*logic; | |||
| return logic == logics.cend() ? nullptr : &*logic; | |||
| } | |||
| const LadderRung *LogicEditorService::findRung( | |||
| const std::string &logic_id, const std::string &rung_id) const | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return nullptr; | |||
| } | |||
| const auto rung = std::find_if( | |||
| logic->rungs.cbegin(), | |||
| logic->rungs.cend(), | |||
| [&rung_id](const LadderRung &candidate) | |||
| { | |||
| return candidate.id == rung_id; | |||
| }); | |||
| return rung == logic->rungs.cend() ? nullptr : &*rung; | |||
| } | |||
| const LogicNode *LogicEditorService::findNode( | |||
| @@ -50,20 +63,99 @@ const LogicNode *LogicEditorService::findNode( | |||
| { | |||
| return nullptr; | |||
| } | |||
| const auto node = std::find_if( | |||
| logic->nodes.cbegin(), | |||
| logic->nodes.cend(), | |||
| [&node_id](const LogicNode &candidate) | |||
| for (const LadderRung &rung : logic->rungs) | |||
| { | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| return candidate.id == node_id; | |||
| }); | |||
| return node == logic->nodes.cend() ? nullptr : &*node; | |||
| const auto node = std::find_if( | |||
| stage.branches.cbegin(), | |||
| stage.branches.cend(), | |||
| [&node_id](const LogicNode &candidate) | |||
| { | |||
| return candidate.id == node_id; | |||
| }); | |||
| if (node != stage.branches.cend()) | |||
| { | |||
| return &*node; | |||
| } | |||
| } | |||
| if (rung.output.has_value() && rung.output->id == node_id) | |||
| { | |||
| return &*rung.output; | |||
| } | |||
| } | |||
| return nullptr; | |||
| } | |||
| std::string LogicEditorService::firstLogicId() const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| return project.controlLogics.empty() ? std::string{} : project.controlLogics.front().id; | |||
| const auto &logics = project_service_.project().controlLogics; | |||
| return logics.empty() ? std::string{} : logics.front().id; | |||
| } | |||
| std::string LogicEditorService::firstRungId(const std::string &logic_id) const | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| return logic == nullptr || logic->rungs.empty() | |||
| ? std::string{} : logic->rungs.front().id; | |||
| } | |||
| std::string LogicEditorService::rungIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return {}; | |||
| } | |||
| for (const LadderRung &rung : logic->rungs) | |||
| { | |||
| if (rung.output.has_value() && rung.output->id == node_id) | |||
| { | |||
| return rung.id; | |||
| } | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| if (std::any_of( | |||
| stage.branches.cbegin(), | |||
| stage.branches.cend(), | |||
| [&node_id](const LogicNode &node) | |||
| { | |||
| return node.id == node_id; | |||
| })) | |||
| { | |||
| return rung.id; | |||
| } | |||
| } | |||
| } | |||
| return {}; | |||
| } | |||
| std::string LogicEditorService::stageIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return {}; | |||
| } | |||
| for (const LadderRung &rung : logic->rungs) | |||
| { | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| if (std::any_of( | |||
| stage.branches.cbegin(), | |||
| stage.branches.cend(), | |||
| [&node_id](const LogicNode &node) | |||
| { | |||
| return node.id == node_id; | |||
| })) | |||
| { | |||
| return stage.id; | |||
| } | |||
| } | |||
| } | |||
| return {}; | |||
| } | |||
| LogicEditorResult LogicEditorService::ensureDefaultLogic() | |||
| @@ -72,33 +164,25 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic() | |||
| { | |||
| return {true, LogicEditorError::None, {}, firstLogicId()}; | |||
| } | |||
| ControlLogic logic; | |||
| logic.id = "logic-1"; | |||
| logic.name = "控制逻辑 1"; | |||
| logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt}); | |||
| Project &project = project_service_.editProject(); | |||
| project.controlLogics.push_back(std::move(logic)); | |||
| return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addNode( | |||
| const std::string &logic_id, | |||
| const LogicNodeConfig &config, | |||
| const LogicPoint &position) | |||
| LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| const std::string id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| std::string error; | |||
| if (!validateCandidateNode(id, config, position, &error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| LadderRung rung; | |||
| rung.id = makeUniqueRungId(*logic); | |||
| rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| @@ -107,230 +191,266 @@ LogicEditorResult LogicEditorService::addNode( | |||
| { | |||
| return candidate.id == logic_id; | |||
| }); | |||
| LogicNode node; | |||
| node.id = id; | |||
| node.config = config; | |||
| node.position = position; | |||
| target->nodes.push_back(std::move(node)); | |||
| return {true, LogicEditorError::None, {}, id}; | |||
| target->rungs.push_back(std::move(rung)); | |||
| return {true, LogicEditorError::None, {}, target->rungs.back().id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::moveNode( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicPoint &position) | |||
| LogicEditorResult LogicEditorService::removeRung( | |||
| const std::string &logic_id, const std::string &rung_id) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LogicNode *node = findNode(logic_id, node_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (node == nullptr) | |||
| if (findRung(logic_id, rung_id) == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| return failure(LogicEditorError::RungNotFound, "ladder rung was not found"); | |||
| } | |||
| if (!position.isValid()) | |||
| if (logic->rungs.size() == 1U) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidNode, | |||
| "logic node position is outside the supported range"); | |||
| return failure(LogicEditorError::InvalidOperation, "control logic requires one rung"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) | |||
| { | |||
| return candidate.id == logic_id; | |||
| }); | |||
| auto editable = std::find_if( | |||
| target->nodes.begin(), | |||
| target->nodes.end(), | |||
| [&node_id](const LogicNode &candidate) | |||
| { | |||
| return candidate.id == node_id; | |||
| }); | |||
| editable->position = position; | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| project.controlLogics.begin(), project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | |||
| target->rungs.erase( | |||
| std::remove_if( | |||
| target->rungs.begin(), target->rungs.end(), | |||
| [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }), | |||
| target->rungs.end()); | |||
| return {true, LogicEditorError::None, {}, rung_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::updateNodeConfig( | |||
| LogicEditorResult LogicEditorService::appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LogicNode *node = findNode(logic_id, node_id); | |||
| const LadderRung *rung = findRung(logic_id, rung_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (node == nullptr) | |||
| if (rung == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| return failure(LogicEditorError::RungNotFound, "ladder rung was not found"); | |||
| } | |||
| if (node->config.index() != config.index()) | |||
| if (!isConditionConfig(config)) | |||
| { | |||
| return failure( | |||
| LogicEditorError::UnsupportedNodeChange, | |||
| "node category cannot be changed after creation"); | |||
| return failure(LogicEditorError::InvalidNode, "ladder condition cannot be a coil"); | |||
| } | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| LogicNode node = makeNode(node_id, config); | |||
| std::string error; | |||
| if (!validateCandidateNode(node_id, config, node->position, &error)) | |||
| if (!node.validate(&error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| LadderStage stage; | |||
| stage.id = makeUniqueStageId(*rung); | |||
| stage.branches.push_back(std::move(node)); | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) | |||
| { | |||
| return candidate.id == logic_id; | |||
| }); | |||
| auto editable = std::find_if( | |||
| target->nodes.begin(), | |||
| target->nodes.end(), | |||
| [&node_id](const LogicNode &candidate) | |||
| { | |||
| return candidate.id == node_id; | |||
| }); | |||
| editable->config = config; | |||
| auto &logics = project.controlLogics; | |||
| auto target_logic = std::find_if( | |||
| logics.begin(), logics.end(), | |||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | |||
| auto target_rung = std::find_if( | |||
| target_logic->rungs.begin(), target_logic->rungs.end(), | |||
| [&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; }); | |||
| target_rung->stages.push_back(std::move(stage)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::connectNodes( | |||
| LogicEditorResult LogicEditorService::addParallelCondition( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| const std::string &rung_id, | |||
| const std::string &stage_id, | |||
| const LogicNodeConfig &config) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LadderRung *rung = findRung(logic_id, rung_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (findNode(logic_id, from_node_id) == nullptr | |||
| || findNode(logic_id, to_node_id) == nullptr) | |||
| if (rung == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic connection node was not found"); | |||
| return failure(LogicEditorError::RungNotFound, "ladder rung was not found"); | |||
| } | |||
| if (hasConnection(*logic, from_node_id, to_node_id)) | |||
| const auto stage = std::find_if( | |||
| rung->stages.cbegin(), rung->stages.cend(), | |||
| [&stage_id](const LadderStage &candidate) { return candidate.id == stage_id; }); | |||
| if (stage == rung->stages.cend()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::DuplicateConnection, | |||
| "logic connection already exists"); | |||
| return failure(LogicEditorError::StageNotFound, "ladder stage was not found"); | |||
| } | |||
| ControlLogic candidate = *logic; | |||
| candidate.connections.push_back({from_node_id, to_node_id}); | |||
| if (!isConditionConfig(config)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "parallel branch requires a condition"); | |||
| } | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| LogicNode node = makeNode(node_id, config); | |||
| std::string error; | |||
| if (!candidate.validateStructure(&error)) | |||
| if (!node.validate(&error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidConnection, error); | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &item) | |||
| { | |||
| return item.id == logic_id; | |||
| }); | |||
| target->connections.push_back({from_node_id, to_node_id}); | |||
| return {true, LogicEditorError::None, {}, to_node_id}; | |||
| auto target_logic = std::find_if( | |||
| project.controlLogics.begin(), project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | |||
| auto target_rung = std::find_if( | |||
| target_logic->rungs.begin(), target_logic->rungs.end(), | |||
| [&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; }); | |||
| auto target_stage = std::find_if( | |||
| target_rung->stages.begin(), target_rung->stages.end(), | |||
| [&stage_id](const LadderStage &candidate) { return candidate.id == stage_id; }); | |||
| target_stage->branches.push_back(std::move(node)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::disconnectNodes( | |||
| LogicEditorResult LogicEditorService::setOutput( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| const auto connection = std::find_if( | |||
| logic->connections.cbegin(), | |||
| logic->connections.cend(), | |||
| [&from_node_id, &to_node_id](const LogicConnection &candidate) | |||
| { | |||
| return candidate.fromNodeId == from_node_id | |||
| && candidate.toNodeId == to_node_id; | |||
| }); | |||
| if (connection == logic->connections.cend()) | |||
| if (findRung(logic_id, rung_id) == nullptr) | |||
| { | |||
| return failure(LogicEditorError::InvalidConnection, "logic connection was not found"); | |||
| return failure(LogicEditorError::RungNotFound, "ladder rung was not found"); | |||
| } | |||
| if (!isOutputConfig(config)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "ladder output must be a coil"); | |||
| } | |||
| const LadderRung *rung = findRung(logic_id, rung_id); | |||
| const std::string node_id = rung->output.has_value() | |||
| ? rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| LogicNode node = makeNode(node_id, config); | |||
| std::string error; | |||
| if (!node.validate(&error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target_logic = std::find_if( | |||
| project.controlLogics.begin(), project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | |||
| auto target_rung = std::find_if( | |||
| target_logic->rungs.begin(), target_logic->rungs.end(), | |||
| [&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; }); | |||
| target_rung->output = std::move(node); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicNodeConfig &config) | |||
| { | |||
| const LogicNode *node = findNode(logic_id, node_id); | |||
| if (node == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| } | |||
| if (node->config.index() != config.index()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::UnsupportedNodeChange, | |||
| "node category cannot be changed after creation"); | |||
| } | |||
| LogicNode candidate = makeNode(node_id, config); | |||
| std::string error; | |||
| if (!candidate.validate(&error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &item) | |||
| for (ControlLogic &logic : project.controlLogics) | |||
| { | |||
| if (logic.id != logic_id) | |||
| { | |||
| return item.id == logic_id; | |||
| }); | |||
| target->connections.erase( | |||
| std::remove_if( | |||
| target->connections.begin(), | |||
| target->connections.end(), | |||
| [&from_node_id, &to_node_id](const LogicConnection &candidate) | |||
| continue; | |||
| } | |||
| for (LadderRung &rung : logic.rungs) | |||
| { | |||
| for (LadderStage &stage : rung.stages) | |||
| { | |||
| for (LogicNode &editable : stage.branches) | |||
| { | |||
| if (editable.id == node_id) | |||
| { | |||
| editable.config = config; | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| } | |||
| } | |||
| if (rung.output.has_value() && rung.output->id == node_id) | |||
| { | |||
| return candidate.fromNodeId == from_node_id | |||
| && candidate.toNodeId == to_node_id; | |||
| }), | |||
| target->connections.end()); | |||
| return {true, LogicEditorError::None, {}, to_node_id}; | |||
| rung.output->config = config; | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| } | |||
| } | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| } | |||
| LogicEditorResult LogicEditorService::removeNode( | |||
| const std::string &logic_id, const std::string &node_id) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (findNode(logic_id, node_id) == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &item) | |||
| for (ControlLogic &logic : project.controlLogics) | |||
| { | |||
| if (logic.id != logic_id) | |||
| { | |||
| return item.id == logic_id; | |||
| }); | |||
| target->nodes.erase( | |||
| std::remove_if( | |||
| target->nodes.begin(), | |||
| target->nodes.end(), | |||
| [&node_id](const LogicNode &candidate) | |||
| continue; | |||
| } | |||
| for (LadderRung &rung : logic.rungs) | |||
| { | |||
| if (rung.output.has_value() && rung.output->id == node_id) | |||
| { | |||
| return candidate.id == node_id; | |||
| }), | |||
| target->nodes.end()); | |||
| target->connections.erase( | |||
| std::remove_if( | |||
| target->connections.begin(), | |||
| target->connections.end(), | |||
| [&node_id](const LogicConnection &candidate) | |||
| rung.output.reset(); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| for (LadderStage &stage : rung.stages) | |||
| { | |||
| return candidate.fromNodeId == node_id || candidate.toNodeId == node_id; | |||
| }), | |||
| target->connections.end()); | |||
| stage.branches.erase( | |||
| std::remove_if( | |||
| stage.branches.begin(), stage.branches.end(), | |||
| [&node_id](const LogicNode &node) { return node.id == node_id; }), | |||
| stage.branches.end()); | |||
| } | |||
| rung.stages.erase( | |||
| std::remove_if( | |||
| rung.stages.begin(), rung.stages.end(), | |||
| [](const LadderStage &stage) { return stage.branches.empty(); }), | |||
| rung.stages.end()); | |||
| } | |||
| } | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config) | |||
| { | |||
| return !std::holds_alternative<CoilNodeConfig>(config); | |||
| } | |||
| bool LogicEditorService::isOutputConfig(const LogicNodeConfig &config) | |||
| { | |||
| return std::holds_alternative<CoilNodeConfig>(config); | |||
| } | |||
| std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | |||
| { | |||
| return std::visit( | |||
| @@ -341,11 +461,14 @@ std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | |||
| { | |||
| return "contact"; | |||
| } | |||
| if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| else if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| { | |||
| return "coil"; | |||
| } | |||
| return "compare"; | |||
| else | |||
| { | |||
| return "compare"; | |||
| } | |||
| }, | |||
| config); | |||
| } | |||
| @@ -355,37 +478,51 @@ std::string LogicEditorService::makeUniqueNodeId( | |||
| { | |||
| for (std::size_t index = 1;; ++index) | |||
| { | |||
| std::ostringstream stream; | |||
| stream << prefix << '-' << index; | |||
| const std::string candidate = stream.str(); | |||
| const auto match = std::find_if( | |||
| logic.nodes.cbegin(), | |||
| logic.nodes.cend(), | |||
| [&candidate](const LogicNode &node) | |||
| const std::string candidate = prefix + '-' + std::to_string(index); | |||
| bool found = false; | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| found = (rung.output.has_value() && rung.output->id == candidate); | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| return node.id == candidate; | |||
| }); | |||
| if (match == logic.nodes.cend()) | |||
| found = found || std::any_of( | |||
| stage.branches.cbegin(), stage.branches.cend(), | |||
| [&candidate](const LogicNode &node) { return node.id == candidate; }); | |||
| } | |||
| } | |||
| if (!found) | |||
| { | |||
| return candidate; | |||
| } | |||
| } | |||
| } | |||
| bool LogicEditorService::hasConnection( | |||
| const ControlLogic &logic, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| std::string LogicEditorService::makeUniqueRungId(const ControlLogic &logic) | |||
| { | |||
| return std::find_if( | |||
| logic.connections.cbegin(), | |||
| logic.connections.cend(), | |||
| [&from_node_id, &to_node_id](const LogicConnection &candidate) | |||
| { | |||
| return candidate.fromNodeId == from_node_id | |||
| && candidate.toNodeId == to_node_id; | |||
| }) | |||
| != logic.connections.cend(); | |||
| for (std::size_t index = 1;; ++index) | |||
| { | |||
| const std::string candidate = "rung-" + std::to_string(index); | |||
| if (std::none_of( | |||
| logic.rungs.cbegin(), logic.rungs.cend(), | |||
| [&candidate](const LadderRung &rung) { return rung.id == candidate; })) | |||
| { | |||
| return candidate; | |||
| } | |||
| } | |||
| } | |||
| std::string LogicEditorService::makeUniqueStageId(const LadderRung &rung) | |||
| { | |||
| for (std::size_t index = 1;; ++index) | |||
| { | |||
| const std::string candidate = "stage-" + std::to_string(index); | |||
| if (std::none_of( | |||
| rung.stages.cbegin(), rung.stages.cend(), | |||
| [&candidate](const LadderStage &stage) { return stage.id == candidate; })) | |||
| { | |||
| return candidate; | |||
| } | |||
| } | |||
| } | |||
| LogicEditorResult LogicEditorService::failure( | |||
| @@ -10,10 +10,11 @@ enum class LogicEditorError | |||
| { | |||
| None, | |||
| LogicNotFound, | |||
| RungNotFound, | |||
| StageNotFound, | |||
| NodeNotFound, | |||
| DuplicateConnection, | |||
| InvalidNode, | |||
| InvalidConnection, | |||
| InvalidOperation, | |||
| UnsupportedNodeChange | |||
| }; | |||
| @@ -31,42 +32,49 @@ public: | |||
| explicit LogicEditorService(ProjectService &project_service); | |||
| const ControlLogic *findLogic(const std::string &logic_id) const; | |||
| const LadderRung *findRung( | |||
| const std::string &logic_id, const std::string &rung_id) const; | |||
| const LogicNode *findNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| std::string firstLogicId() const; | |||
| std::string firstRungId(const std::string &logic_id) const; | |||
| std::string rungIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| std::string stageIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| LogicEditorResult ensureDefaultLogic(); | |||
| LogicEditorResult addNode( | |||
| LogicEditorResult addRung(const std::string &logic_id); | |||
| LogicEditorResult removeRung( | |||
| const std::string &logic_id, const std::string &rung_id); | |||
| LogicEditorResult appendCondition( | |||
| const std::string &logic_id, | |||
| const LogicNodeConfig &config, | |||
| const LogicPoint &position); | |||
| LogicEditorResult moveNode( | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult addParallelCondition( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicPoint &position); | |||
| const std::string &rung_id, | |||
| const std::string &stage_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult setOutput( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult connectNodes( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id); | |||
| LogicEditorResult disconnectNodes( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id); | |||
| LogicEditorResult removeNode( | |||
| const std::string &logic_id, const std::string &node_id); | |||
| private: | |||
| static bool isConditionConfig(const LogicNodeConfig &config); | |||
| static bool isOutputConfig(const LogicNodeConfig &config); | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| static std::string makeUniqueNodeId( | |||
| const ControlLogic &logic, const std::string &prefix); | |||
| static bool hasConnection( | |||
| const ControlLogic &logic, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id); | |||
| static std::string makeUniqueRungId(const ControlLogic &logic); | |||
| static std::string makeUniqueStageId(const LadderRung &rung); | |||
| static LogicEditorResult failure( | |||
| LogicEditorError error, const std::string &message); | |||
| @@ -1,95 +1,68 @@ | |||
| #include "logic_editor_widget.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include <QGraphicsPathItem> | |||
| #include <QGraphicsItem> | |||
| #include <QGraphicsScene> | |||
| #include <QGraphicsSceneMouseEvent> | |||
| #include <QMouseEvent> | |||
| #include <QPainter> | |||
| #include <QResizeEvent> | |||
| #include <QStyleOptionGraphicsItem> | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <functional> | |||
| #include <map> | |||
| #include <type_traits> | |||
| #include <utility> | |||
| namespace { | |||
| constexpr qreal kNodeWidth = 168.0; | |||
| constexpr qreal kNodeHeight = 72.0; | |||
| constexpr qreal kPortRadius = 6.0; | |||
| constexpr qreal kMinimumSceneWidth = 1280.0; | |||
| constexpr qreal kLeftRailX = 72.0; | |||
| constexpr qreal kStageWidth = 184.0; | |||
| constexpr qreal kNodeWidth = 118.0; | |||
| constexpr qreal kBranchSpacing = 58.0; | |||
| constexpr qreal kRungGap = 30.0; | |||
| QString nodeTitle(const LogicNodeConfig &config) | |||
| QString comparisonText(ComparisonOperator comparison) | |||
| { | |||
| return std::visit( | |||
| [](const auto &value) -> QString | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| { | |||
| return value.mode == ContactMode::NormallyOpen | |||
| ? QStringLiteral("常开触点") | |||
| : QStringLiteral("常闭触点"); | |||
| } | |||
| if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| { | |||
| if (value.mode == CoilMode::Set) | |||
| { | |||
| return QStringLiteral("置位线圈"); | |||
| } | |||
| if (value.mode == CoilMode::Reset) | |||
| { | |||
| return QStringLiteral("复位线圈"); | |||
| } | |||
| return QStringLiteral("普通线圈"); | |||
| } | |||
| return QStringLiteral("D 值比较"); | |||
| }, | |||
| config); | |||
| switch (comparison) | |||
| { | |||
| case ComparisonOperator::Equal: return QStringLiteral("="); | |||
| case ComparisonOperator::NotEqual: return QStringLiteral("<>"); | |||
| case ComparisonOperator::LessThan: return QStringLiteral("<"); | |||
| case ComparisonOperator::LessThanOrEqual: return QStringLiteral("<="); | |||
| case ComparisonOperator::GreaterThan: return QStringLiteral(">"); | |||
| case ComparisonOperator::GreaterThanOrEqual: return QStringLiteral(">="); | |||
| default: return QStringLiteral("?"); | |||
| } | |||
| } | |||
| QString nodeDetail(const LogicNodeConfig &config) | |||
| QString nodeAddressText(const LogicNodeConfig &config) | |||
| { | |||
| return std::visit( | |||
| [](const auto &value) -> QString | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| { | |||
| return QStringLiteral("M%1").arg(value.address.index()); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig> | |||
| || std::is_same_v<Config, CoilNodeConfig>) | |||
| { | |||
| return QStringLiteral("M%1").arg(value.address.index()); | |||
| } | |||
| else | |||
| { | |||
| return QStringLiteral("D%1 = %2") | |||
| return QStringLiteral("D%1 %2 %3") | |||
| .arg(value.address.index()) | |||
| .arg(comparisonText(value.comparison)) | |||
| .arg(value.value); | |||
| } | |||
| }, | |||
| config); | |||
| } | |||
| bool isCoil(const LogicNodeConfig &config) | |||
| qreal rungHeight(const LadderRung &rung) | |||
| { | |||
| return std::holds_alternative<CoilNodeConfig>(config); | |||
| } | |||
| QPainterPath makeConnectionPath(const QPointF &from, const QPointF &to) | |||
| { | |||
| QPainterPath path(from); | |||
| const qreal offset = std::max(48.0, std::abs(to.x() - from.x()) * 0.45); | |||
| path.cubicTo( | |||
| from + QPointF(offset, 0), | |||
| to - QPointF(offset, 0), | |||
| to); | |||
| return path; | |||
| std::size_t maximum_branches = 1U; | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| maximum_branches = std::max(maximum_branches, stage.branches.size()); | |||
| } | |||
| return 100.0 + static_cast<qreal>(maximum_branches - 1U) * kBranchSpacing; | |||
| } | |||
| } // namespace | |||
| @@ -99,22 +72,21 @@ class LogicEditorWidget::NodeItem final : public QGraphicsItem | |||
| public: | |||
| NodeItem( | |||
| const LogicNode &node, | |||
| std::function<void(const std::string &, const QPointF &)> moved, | |||
| std::function<void(const std::string &)> position_changed) | |||
| const std::string &rung_id, | |||
| const std::string &stage_id, | |||
| const QPointF ¢er) | |||
| : node_id_(node.id), | |||
| config_(node.config), | |||
| moved_(std::move(moved)), | |||
| position_changed_(std::move(position_changed)) | |||
| rung_id_(rung_id), | |||
| stage_id_(stage_id), | |||
| config_(node.config) | |||
| { | |||
| setPos(node.position.x, node.position.y); | |||
| setPos(center); | |||
| setFlag(ItemIsSelectable, true); | |||
| setFlag(ItemSendsGeometryChanges, true); | |||
| setAcceptedMouseButtons(Qt::LeftButton); | |||
| } | |||
| QRectF boundingRect() const override | |||
| { | |||
| return {0, 0, kNodeWidth, kNodeHeight}; | |||
| return {-kNodeWidth / 2.0, -27.0, kNodeWidth, 54.0}; | |||
| } | |||
| void paint( | |||
| @@ -122,135 +94,115 @@ public: | |||
| const QStyleOptionGraphicsItem *option, | |||
| QWidget *) override | |||
| { | |||
| const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); | |||
| painter->setRenderHint(QPainter::Antialiasing, true); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1)); | |||
| painter->setBrush(isCoil(config_) | |||
| ? QColor(QStringLiteral("#fff2d8")) | |||
| : QColor(QStringLiteral("#e8f1f6"))); | |||
| painter->drawRoundedRect(rect, 5, 5); | |||
| painter->setPen(QColor(QStringLiteral("#18252e"))); | |||
| painter->drawText( | |||
| rect.adjusted(14, 8, -14, -34), | |||
| Qt::AlignLeft | Qt::AlignVCenter, | |||
| nodeTitle(config_)); | |||
| painter->setPen(QColor(QStringLiteral("#586873"))); | |||
| painter->drawText( | |||
| rect.adjusted(14, 35, -14, -8), | |||
| Qt::AlignLeft | Qt::AlignVCenter, | |||
| nodeDetail(config_)); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1)); | |||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||
| painter->drawEllipse(inputPortRect()); | |||
| if (!isCoil(config_)) | |||
| const bool selected = (option->state & QStyle::State_Selected) != 0; | |||
| if (selected) | |||
| { | |||
| painter->drawEllipse(outputPortRect()); | |||
| painter->fillRect( | |||
| boundingRect(), QColor(QStringLiteral("#dceef7"))); | |||
| } | |||
| painter->setPen(QPen( | |||
| selected ? QColor(QStringLiteral("#1677a8")) | |||
| : QColor(QStringLiteral("#1f2b33")), | |||
| selected ? 2.0 : 1.6)); | |||
| if ((option->state & QStyle::State_Selected) != 0) | |||
| if (const auto *contact = std::get_if<ContactNodeConfig>(&config_)) | |||
| { | |||
| painter->setBrush(Qt::NoBrush); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); | |||
| painter->drawRoundedRect(boundingRect().adjusted(0, 0, -1, -1), 5, 5); | |||
| painter->drawLine(QPointF(-18, -15), QPointF(-18, 15)); | |||
| painter->drawLine(QPointF(18, -15), QPointF(18, 15)); | |||
| if (contact->mode == ContactMode::NormallyClosed) | |||
| { | |||
| painter->drawLine(QPointF(-23, 18), QPointF(23, -18)); | |||
| } | |||
| } | |||
| } | |||
| const std::string &nodeId() const | |||
| { | |||
| return node_id_; | |||
| } | |||
| bool hasOutput() const | |||
| { | |||
| return !isCoil(config_); | |||
| } | |||
| QRectF inputPortRect() const | |||
| { | |||
| return {0, kNodeHeight / 2.0 - kPortRadius, | |||
| 2.0 * kPortRadius, 2.0 * kPortRadius}; | |||
| } | |||
| QRectF outputPortRect() const | |||
| { | |||
| return {kNodeWidth - 2.0 * kPortRadius, kNodeHeight / 2.0 - kPortRadius, | |||
| 2.0 * kPortRadius, 2.0 * kPortRadius}; | |||
| } | |||
| QPointF inputPortScenePosition() const | |||
| { | |||
| return mapToScene(inputPortRect().center()); | |||
| } | |||
| QPointF outputPortScenePosition() const | |||
| { | |||
| return mapToScene(outputPortRect().center()); | |||
| } | |||
| void setInteractionEnabled(bool enabled) | |||
| { | |||
| setFlag(ItemIsMovable, enabled); | |||
| } | |||
| protected: | |||
| QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | |||
| { | |||
| if (change == ItemPositionHasChanged && position_changed_) | |||
| else if (const auto *coil = std::get_if<CoilNodeConfig>(&config_)) | |||
| { | |||
| position_changed_(node_id_); | |||
| painter->drawArc(QRectF(-29, -20, 30, 40), -80 * 16, 160 * 16); | |||
| painter->drawArc(QRectF(-1, -20, 30, 40), 100 * 16, 160 * 16); | |||
| if (coil->mode == CoilMode::Set || coil->mode == CoilMode::Reset) | |||
| { | |||
| painter->drawText( | |||
| QRectF(-12, -14, 24, 28), | |||
| Qt::AlignCenter, | |||
| coil->mode == CoilMode::Set ? QStringLiteral("S") | |||
| : QStringLiteral("R")); | |||
| } | |||
| } | |||
| return QGraphicsItem::itemChange(change, value); | |||
| } | |||
| void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| QGraphicsItem::mouseReleaseEvent(event); | |||
| if (flags().testFlag(ItemIsMovable) && moved_) | |||
| else | |||
| { | |||
| moved_(node_id_, pos()); | |||
| painter->drawRect(QRectF(-43, -18, 86, 36)); | |||
| } | |||
| painter->setPen(QColor(QStringLiteral("#263640"))); | |||
| painter->drawText( | |||
| QRectF(-kNodeWidth / 2.0, -42, kNodeWidth, 18), | |||
| Qt::AlignCenter, | |||
| nodeAddressText(config_)); | |||
| } | |||
| const std::string &nodeId() const { return node_id_; } | |||
| const std::string &rungId() const { return rung_id_; } | |||
| const std::string &stageId() const { return stage_id_; } | |||
| private: | |||
| std::string node_id_; | |||
| std::string rung_id_; | |||
| std::string stage_id_; | |||
| LogicNodeConfig config_; | |||
| std::function<void(const std::string &, const QPointF &)> moved_; | |||
| std::function<void(const std::string &)> position_changed_; | |||
| }; | |||
| class LogicEditorWidget::ConnectionItem final : public QGraphicsPathItem | |||
| class LogicEditorWidget::RungItem final : public QGraphicsItem | |||
| { | |||
| public: | |||
| ConnectionItem( | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| : from_node_id_(from_node_id), to_node_id_(to_node_id) | |||
| { | |||
| RungItem( | |||
| const LadderRung &rung, | |||
| int number, | |||
| qreal top, | |||
| qreal height, | |||
| qreal scene_width) | |||
| : rung_id_(rung.id), | |||
| name_(QString::fromUtf8(rung.name.data(), static_cast<int>(rung.name.size()))), | |||
| number_(number), | |||
| height_(height), | |||
| scene_width_(scene_width) | |||
| { | |||
| setPos(0, top); | |||
| setFlag(ItemIsSelectable, true); | |||
| setZValue(-1.0); | |||
| setPen(QPen(QColor(QStringLiteral("#5d6f7b")), 2)); | |||
| setZValue(-2.0); | |||
| } | |||
| const std::string &fromNodeId() const | |||
| QRectF boundingRect() const override | |||
| { | |||
| return from_node_id_; | |||
| return {20, 0, scene_width_ - 40, height_}; | |||
| } | |||
| const std::string &toNodeId() const | |||
| void paint( | |||
| QPainter *painter, | |||
| const QStyleOptionGraphicsItem *option, | |||
| QWidget *) override | |||
| { | |||
| return to_node_id_; | |||
| if ((option->state & QStyle::State_Selected) != 0) | |||
| { | |||
| painter->fillRect( | |||
| boundingRect(), QColor(QStringLiteral("#f0f7fa"))); | |||
| } | |||
| painter->setPen(QColor(QStringLiteral("#62717b"))); | |||
| painter->drawText( | |||
| QRectF(28, 6, 260, 22), | |||
| Qt::AlignLeft | Qt::AlignVCenter, | |||
| QStringLiteral("%1 %2").arg(number_).arg(name_)); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#d4dce1")), 1)); | |||
| painter->drawLine( | |||
| QPointF(28, height_ - 1), QPointF(scene_width_ - 28, height_ - 1)); | |||
| } | |||
| void updatePath(const QPointF &from, const QPointF &to) | |||
| { | |||
| setPath(makeConnectionPath(from, to)); | |||
| } | |||
| const std::string &rungId() const { return rung_id_; } | |||
| private: | |||
| std::string from_node_id_; | |||
| std::string to_node_id_; | |||
| std::string rung_id_; | |||
| QString name_; | |||
| int number_ = 0; | |||
| qreal height_ = 0; | |||
| qreal scene_width_ = 0; | |||
| }; | |||
| LogicEditorWidget::LogicEditorWidget( | |||
| @@ -259,12 +211,11 @@ LogicEditorWidget::LogicEditorWidget( | |||
| : QGraphicsView(parent), editor_service_(editor_service) | |||
| { | |||
| scene_ = new QGraphicsScene(this); | |||
| scene_->setSceneRect(0, 0, 2400, 1400); | |||
| setScene(scene_); | |||
| setRenderHint(QPainter::Antialiasing, true); | |||
| setBackgroundBrush(QColor(QStringLiteral("#ffffff"))); | |||
| setDragMode(QGraphicsView::RubberBandDrag); | |||
| setTransformationAnchor(QGraphicsView::AnchorUnderMouse); | |||
| setAlignment(Qt::AlignLeft | Qt::AlignTop); | |||
| connect(scene_, &QGraphicsScene::selectionChanged, | |||
| this, &LogicEditorWidget::handleSelectionChanged); | |||
| } | |||
| @@ -276,75 +227,124 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) | |||
| return; | |||
| } | |||
| logic_id_ = logic_id; | |||
| current_rung_id_.clear(); | |||
| reloadLogic(); | |||
| } | |||
| void LogicEditorWidget::setEditingEnabled(bool enabled) | |||
| { | |||
| editing_enabled_ = enabled; | |||
| setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag); | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| node->setInteractionEnabled(enabled); | |||
| } | |||
| } | |||
| if (!enabled) | |||
| { | |||
| clearPreview(); | |||
| } | |||
| setInteractive(enabled); | |||
| } | |||
| void LogicEditorWidget::reloadLogic() | |||
| { | |||
| clearPreview(); | |||
| scene_->clear(); | |||
| if (logic_id_.empty()) | |||
| { | |||
| return; | |||
| } | |||
| const ControlLogic *logic = editor_service_.findLogic(logic_id_); | |||
| if (logic == nullptr) | |||
| { | |||
| scene_->setSceneRect(0, 0, kMinimumSceneWidth, 400); | |||
| return; | |||
| } | |||
| std::map<std::string, NodeItem *> node_items; | |||
| for (const LogicConnection &connection : logic->connections) | |||
| std::size_t maximum_stage_count = 0U; | |||
| for (const LadderRung &rung : logic->rungs) | |||
| { | |||
| scene_->addItem(new ConnectionItem(connection.fromNodeId, connection.toNodeId)); | |||
| maximum_stage_count = std::max(maximum_stage_count, rung.stages.size()); | |||
| } | |||
| for (const LogicNode &node : logic->nodes) | |||
| const qreal scene_width = std::max( | |||
| kMinimumSceneWidth, | |||
| kLeftRailX + static_cast<qreal>(maximum_stage_count) * kStageWidth + 300.0); | |||
| const qreal right_rail_x = scene_width - 72.0; | |||
| if (current_rung_id_.empty() && !logic->rungs.empty()) | |||
| { | |||
| auto *item = new NodeItem( | |||
| node, | |||
| [this](const std::string &node_id, const QPointF &position) | |||
| current_rung_id_ = logic->rungs.front().id; | |||
| } | |||
| qreal top = 24.0; | |||
| int rung_number = 1; | |||
| for (const LadderRung &rung : logic->rungs) | |||
| { | |||
| const qreal height = rungHeight(rung); | |||
| scene_->addItem(new RungItem(rung, rung_number, top, height, scene_width)); | |||
| const qreal main_y = top + 62.0; | |||
| scene_->addLine( | |||
| kLeftRailX, top + 32.0, kLeftRailX, top + height - 18.0, | |||
| QPen(QColor(QStringLiteral("#202a31")), 3)); | |||
| scene_->addLine( | |||
| right_rail_x, top + 32.0, right_rail_x, top + height - 18.0, | |||
| QPen(QColor(QStringLiteral("#202a31")), 3)); | |||
| qreal cursor_x = kLeftRailX; | |||
| for (const LadderStage &stage : rung.stages) | |||
| { | |||
| const qreal next_x = cursor_x + kStageWidth; | |||
| const qreal left_join = cursor_x + 18.0; | |||
| const qreal right_join = next_x - 18.0; | |||
| scene_->addLine( | |||
| cursor_x, main_y, left_join, main_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| scene_->addLine( | |||
| right_join, main_y, next_x, main_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| if (stage.branches.size() > 1U) | |||
| { | |||
| handleNodePositionChanged(node_id); | |||
| const LogicPoint point{ | |||
| static_cast<int>(std::lround(position.x())), | |||
| static_cast<int>(std::lround(position.y()))}; | |||
| const LogicEditorResult result = editor_service_.moveNode( | |||
| logic_id_, node_id, point); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(result); | |||
| reloadLogic(); | |||
| return; | |||
| } | |||
| emit nodeChanged(QString::fromStdString(node_id)); | |||
| }, | |||
| [this](const std::string &) | |||
| const qreal bottom_y = main_y | |||
| + static_cast<qreal>(stage.branches.size() - 1U) * kBranchSpacing; | |||
| scene_->addLine( | |||
| left_join, main_y, left_join, bottom_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| scene_->addLine( | |||
| right_join, main_y, right_join, bottom_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| } | |||
| for (std::size_t branch = 0; branch < stage.branches.size(); ++branch) | |||
| { | |||
| updateConnections(); | |||
| }); | |||
| item->setInteractionEnabled(editing_enabled_); | |||
| scene_->addItem(item); | |||
| node_items.emplace(node.id, item); | |||
| const qreal branch_y = main_y + static_cast<qreal>(branch) * kBranchSpacing; | |||
| scene_->addLine( | |||
| left_join, branch_y, right_join, branch_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| scene_->addItem(new NodeItem( | |||
| stage.branches.at(branch), | |||
| rung.id, | |||
| stage.id, | |||
| QPointF((left_join + right_join) / 2.0, branch_y))); | |||
| } | |||
| cursor_x = next_x; | |||
| } | |||
| const qreal output_x = right_rail_x - 116.0; | |||
| scene_->addLine( | |||
| cursor_x, main_y, output_x, main_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| scene_->addLine( | |||
| output_x, main_y, right_rail_x, main_y, | |||
| QPen(QColor(QStringLiteral("#202a31")), 2)); | |||
| if (rung.output.has_value()) | |||
| { | |||
| scene_->addItem(new NodeItem( | |||
| *rung.output, | |||
| rung.id, | |||
| {}, | |||
| QPointF(output_x + 50.0, main_y))); | |||
| } | |||
| else | |||
| { | |||
| auto *placeholder = scene_->addText(QStringLiteral("< 输出线圈 >")); | |||
| placeholder->setDefaultTextColor(QColor(QStringLiteral("#87949c"))); | |||
| placeholder->setPos(output_x + 4.0, main_y - 14.0); | |||
| } | |||
| if (rung.stages.empty()) | |||
| { | |||
| auto *placeholder = scene_->addText(QStringLiteral("< 添加条件 >")); | |||
| placeholder->setDefaultTextColor(QColor(QStringLiteral("#87949c"))); | |||
| placeholder->setPos(kLeftRailX + 32.0, main_y - 14.0); | |||
| } | |||
| top += height + kRungGap; | |||
| ++rung_number; | |||
| } | |||
| updateConnections(); | |||
| scene_->setSceneRect(0, 0, scene_width, std::max(400.0, top + 20.0)); | |||
| } | |||
| void LogicEditorWidget::selectNode(const std::string &node_id) | |||
| @@ -376,249 +376,182 @@ std::string LogicEditorWidget::selectedNodeId() const | |||
| return {}; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::addNode(const LogicNodeConfig &config) | |||
| { | |||
| if (logic_id_.empty()) | |||
| { | |||
| const LogicEditorResult logic_result = editor_service_.ensureDefaultLogic(); | |||
| if (!logic_result.succeeded) | |||
| { | |||
| reportFailure(logic_result); | |||
| return logic_result; | |||
| } | |||
| logic_id_ = logic_result.id; | |||
| } | |||
| const LogicEditorResult result = editor_service_.addNode( | |||
| logic_id_, config, nextNodePosition()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(result); | |||
| return result; | |||
| } | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| emit nodeChanged(QString::fromStdString(result.id)); | |||
| emit graphChanged(); | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::deleteSelected() | |||
| std::string LogicEditorWidget::selectedRungId() const | |||
| { | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| const NodeItem *node = dynamic_cast<const NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| const LogicEditorResult result = editor_service_.removeNode( | |||
| logic_id_, node->nodeId()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(result); | |||
| return result; | |||
| } | |||
| reloadLogic(); | |||
| emit nodeSelected({}); | |||
| emit graphChanged(); | |||
| return result; | |||
| return node->rungId(); | |||
| } | |||
| ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item); | |||
| if (connection != nullptr) | |||
| const RungItem *rung = dynamic_cast<const RungItem *>(item); | |||
| if (rung != nullptr) | |||
| { | |||
| const LogicEditorResult result = editor_service_.disconnectNodes( | |||
| logic_id_, connection->fromNodeId(), connection->toNodeId()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(result); | |||
| return result; | |||
| } | |||
| reloadLogic(); | |||
| emit graphChanged(); | |||
| return result; | |||
| return rung->rungId(); | |||
| } | |||
| } | |||
| return {false, LogicEditorError::NodeNotFound, "no logic object is selected", {}}; | |||
| return {}; | |||
| } | |||
| void LogicEditorWidget::mousePressEvent(QMouseEvent *event) | |||
| std::string LogicEditorWidget::selectedStageId() const | |||
| { | |||
| if (editing_enabled_ && event->button() == Qt::LeftButton) | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| NodeItem *output = nodeAtPort(mapToScene(event->pos()), true); | |||
| if (output != nullptr) | |||
| const NodeItem *node = dynamic_cast<const NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| connecting_from_node_id_ = output->nodeId(); | |||
| preview_item_ = scene_->addPath(QPainterPath()); | |||
| preview_item_->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2, Qt::DashLine)); | |||
| preview_item_->setZValue(-0.5); | |||
| updatePreview(mapToScene(event->pos())); | |||
| event->accept(); | |||
| return; | |||
| return node->stageId(); | |||
| } | |||
| } | |||
| QGraphicsView::mousePressEvent(event); | |||
| return {}; | |||
| } | |||
| void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) | |||
| LogicEditorResult LogicEditorWidget::addRung() | |||
| { | |||
| if (!connecting_from_node_id_.empty()) | |||
| const LogicEditorResult result = editor_service_.addRung(logic_id_); | |||
| if (result.succeeded) | |||
| { | |||
| updatePreview(mapToScene(event->pos())); | |||
| event->accept(); | |||
| return; | |||
| current_rung_id_ = result.id; | |||
| reloadLogic(); | |||
| emit graphChanged(); | |||
| } | |||
| QGraphicsView::mouseMoveEvent(event); | |||
| } | |||
| void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) | |||
| { | |||
| if (!connecting_from_node_id_.empty()) | |||
| else | |||
| { | |||
| NodeItem *input = nodeAtPort(mapToScene(event->pos()), false); | |||
| const std::string from = connecting_from_node_id_; | |||
| clearPreview(); | |||
| if (input != nullptr && input->nodeId() != from) | |||
| { | |||
| const LogicEditorResult result = editor_service_.connectNodes( | |||
| logic_id_, from, input->nodeId()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| else | |||
| { | |||
| reloadLogic(); | |||
| emit graphChanged(); | |||
| } | |||
| } | |||
| event->accept(); | |||
| return; | |||
| reportFailure(result); | |||
| } | |||
| QGraphicsView::mouseReleaseEvent(event); | |||
| } | |||
| void LogicEditorWidget::resizeEvent(QResizeEvent *event) | |||
| { | |||
| QGraphicsView::resizeEvent(event); | |||
| updateConnections(); | |||
| } | |||
| void LogicEditorWidget::handleSelectionChanged() | |||
| { | |||
| emit nodeSelected(QString::fromStdString(selectedNodeId())); | |||
| } | |||
| void LogicEditorWidget::handleNodePositionChanged(const std::string &) | |||
| { | |||
| updateConnections(); | |||
| return result; | |||
| } | |||
| void LogicEditorWidget::updateConnections() | |||
| LogicEditorResult LogicEditorWidget::appendCondition(const LogicNodeConfig &config) | |||
| { | |||
| std::map<std::string, NodeItem *> node_items; | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| const LogicEditorResult result = editor_service_.appendCondition( | |||
| logic_id_, currentRungId(), config); | |||
| if (result.succeeded) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| node_items.emplace(node->nodeId(), node); | |||
| } | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| else | |||
| { | |||
| ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item); | |||
| if (connection == nullptr) | |||
| { | |||
| continue; | |||
| } | |||
| const auto from = node_items.find(connection->fromNodeId()); | |||
| const auto to = node_items.find(connection->toNodeId()); | |||
| if (from != node_items.end() && to != node_items.end()) | |||
| { | |||
| connection->updatePath( | |||
| from->second->outputPortScenePosition(), | |||
| to->second->inputPortScenePosition()); | |||
| } | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| void LogicEditorWidget::updatePreview(const QPointF &scene_position) | |||
| LogicEditorResult LogicEditorWidget::addParallelCondition( | |||
| const LogicNodeConfig &config) | |||
| { | |||
| if (preview_item_ == nullptr) | |||
| const std::string rung_id = selectedRungId(); | |||
| const std::string stage_id = selectedStageId(); | |||
| LogicEditorResult result; | |||
| if (rung_id.empty() || stage_id.empty()) | |||
| { | |||
| return; | |||
| result = {false, | |||
| LogicEditorError::InvalidOperation, | |||
| "select a condition before adding a parallel branch", | |||
| {}}; | |||
| } | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| else | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr && node->nodeId() == connecting_from_node_id_) | |||
| { | |||
| preview_item_->setPath( | |||
| makeConnectionPath(node->outputPortScenePosition(), scene_position)); | |||
| return; | |||
| } | |||
| result = editor_service_.addParallelCondition( | |||
| logic_id_, rung_id, stage_id, config); | |||
| } | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| void LogicEditorWidget::clearPreview() | |||
| LogicEditorResult LogicEditorWidget::setOutput(const LogicNodeConfig &config) | |||
| { | |||
| if (preview_item_ != nullptr) | |||
| const LogicEditorResult result = editor_service_.setOutput( | |||
| logic_id_, currentRungId(), config); | |||
| if (result.succeeded) | |||
| { | |||
| scene_->removeItem(preview_item_); | |||
| delete preview_item_; | |||
| preview_item_ = nullptr; | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| connecting_from_node_id_.clear(); | |||
| return result; | |||
| } | |||
| LogicEditorWidget::NodeItem *LogicEditorWidget::nodeAtPort( | |||
| const QPointF &scene_position, bool output) const | |||
| LogicEditorResult LogicEditorWidget::deleteSelected() | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| const std::string node_id = selectedNodeId(); | |||
| LogicEditorResult result; | |||
| if (!node_id.empty()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node == nullptr || (output && !node->hasOutput())) | |||
| result = editor_service_.removeNode(logic_id_, node_id); | |||
| } | |||
| else | |||
| { | |||
| const std::string rung_id = selectedRungId(); | |||
| if (rung_id.empty()) | |||
| { | |||
| continue; | |||
| return {false, LogicEditorError::InvalidOperation, "no ladder object is selected", {}}; | |||
| } | |||
| const QPointF port = output | |||
| ? node->outputPortScenePosition() | |||
| : node->inputPortScenePosition(); | |||
| if (QLineF(port, scene_position).length() <= 12.0) | |||
| result = editor_service_.removeRung(logic_id_, rung_id); | |||
| if (result.succeeded && current_rung_id_ == rung_id) | |||
| { | |||
| return node; | |||
| current_rung_id_ = editor_service_.firstRungId(logic_id_); | |||
| } | |||
| } | |||
| return nullptr; | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| emit nodeSelected({}); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| LogicPoint LogicEditorWidget::nextNodePosition() const | |||
| void LogicEditorWidget::resizeEvent(QResizeEvent *event) | |||
| { | |||
| const ControlLogic *logic = editor_service_.findLogic(logic_id_); | |||
| for (std::size_t index = 0;; ++index) | |||
| QGraphicsView::resizeEvent(event); | |||
| } | |||
| void LogicEditorWidget::handleSelectionChanged() | |||
| { | |||
| const std::string rung_id = selectedRungId(); | |||
| if (!rung_id.empty()) | |||
| { | |||
| const LogicPoint candidate{ | |||
| 80 + static_cast<int>(index % 5U) * 230, | |||
| 80 + static_cast<int>(index / 5U) * 130}; | |||
| if (logic == nullptr) | |||
| { | |||
| return candidate; | |||
| } | |||
| const auto occupied = std::find_if( | |||
| logic->nodes.cbegin(), | |||
| logic->nodes.cend(), | |||
| [&candidate](const LogicNode &node) | |||
| { | |||
| return std::abs(node.position.x - candidate.x) < 40 | |||
| && std::abs(node.position.y - candidate.y) < 40; | |||
| }); | |||
| if (occupied == logic->nodes.cend()) | |||
| { | |||
| return candidate; | |||
| } | |||
| current_rung_id_ = rung_id; | |||
| } | |||
| emit nodeSelected(QString::fromStdString(selectedNodeId())); | |||
| } | |||
| void LogicEditorWidget::reportFailure(const LogicEditorResult &result) | |||
| { | |||
| emit editorError(QString::fromStdString(result.message)); | |||
| } | |||
| std::string LogicEditorWidget::currentRungId() const | |||
| { | |||
| const std::string selected = selectedRungId(); | |||
| if (!selected.empty()) | |||
| { | |||
| return selected; | |||
| } | |||
| if (!current_rung_id_.empty()) | |||
| { | |||
| return current_rung_id_; | |||
| } | |||
| return editor_service_.firstRungId(logic_id_); | |||
| } | |||
| @@ -1,16 +1,13 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| class LogicEditorService; | |||
| struct LogicEditorResult; | |||
| class QGraphicsPathItem; | |||
| class QGraphicsScene; | |||
| class QMouseEvent; | |||
| class QResizeEvent; | |||
| class LogicEditorWidget final : public QGraphicsView | |||
| @@ -27,38 +24,34 @@ public: | |||
| void reloadLogic(); | |||
| void selectNode(const std::string &node_id); | |||
| std::string selectedNodeId() const; | |||
| LogicEditorResult addNode(const LogicNodeConfig &config); | |||
| std::string selectedRungId() const; | |||
| std::string selectedStageId() const; | |||
| LogicEditorResult addRung(); | |||
| LogicEditorResult appendCondition(const LogicNodeConfig &config); | |||
| LogicEditorResult addParallelCondition(const LogicNodeConfig &config); | |||
| LogicEditorResult setOutput(const LogicNodeConfig &config); | |||
| LogicEditorResult deleteSelected(); | |||
| signals: | |||
| void nodeSelected(const QString &node_id); | |||
| void nodeChanged(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 resizeEvent(QResizeEvent *event) override; | |||
| private: | |||
| class NodeItem; | |||
| class ConnectionItem; | |||
| class RungItem; | |||
| void handleSelectionChanged(); | |||
| void handleNodePositionChanged(const std::string &node_id); | |||
| void updateConnections(); | |||
| void updatePreview(const QPointF &scene_position); | |||
| void clearPreview(); | |||
| NodeItem *nodeAtPort(const QPointF &scene_position, bool output) const; | |||
| LogicPoint nextNodePosition() const; | |||
| void reportFailure(const LogicEditorResult &result); | |||
| std::string currentRungId() const; | |||
| LogicEditorService &editor_service_; | |||
| QGraphicsScene *scene_ = nullptr; | |||
| std::string logic_id_; | |||
| std::string current_rung_id_; | |||
| bool editing_enabled_ = true; | |||
| std::string connecting_from_node_id_; | |||
| QGraphicsPathItem *preview_item_ = nullptr; | |||
| }; | |||
| @@ -231,6 +231,12 @@ void MainWindow::configureActions() | |||
| logic_tool_bar_ = addToolBar(tr("控制逻辑节点")); | |||
| logic_tool_bar_->setObjectName(QStringLiteral("logicToolBar")); | |||
| logic_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); | |||
| add_rung_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_FileIcon), tr("新建网络")); | |||
| parallel_insert_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_ArrowDown), tr("并联插入")); | |||
| parallel_insert_action_->setCheckable(true); | |||
| logic_tool_bar_->addSeparator(); | |||
| add_normally_open_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_ArrowRight), tr("常开")); | |||
| add_normally_closed_action_ = logic_tool_bar_->addAction( | |||
| @@ -245,6 +251,8 @@ void MainWindow::configureActions() | |||
| style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("D 比较")); | |||
| delete_logic_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_TrashIcon), tr("删除")); | |||
| add_rung_action_->setObjectName(QStringLiteral("addRungAction")); | |||
| parallel_insert_action_->setObjectName(QStringLiteral("parallelInsertAction")); | |||
| add_normally_open_action_->setObjectName(QStringLiteral("addNormallyOpenAction")); | |||
| add_normally_closed_action_->setObjectName(QStringLiteral("addNormallyClosedAction")); | |||
| add_normal_coil_action_->setObjectName(QStringLiteral("addNormalCoilAction")); | |||
| @@ -252,18 +260,22 @@ void MainWindow::configureActions() | |||
| add_reset_coil_action_->setObjectName(QStringLiteral("addResetCoilAction")); | |||
| add_compare_action_->setObjectName(QStringLiteral("addCompareAction")); | |||
| delete_logic_action_->setObjectName(QStringLiteral("deleteLogicAction")); | |||
| add_normally_open_action_->setToolTip(tr("添加常开触点")); | |||
| add_normally_closed_action_->setToolTip(tr("添加常闭触点")); | |||
| add_normal_coil_action_->setToolTip(tr("添加普通线圈")); | |||
| add_set_coil_action_->setToolTip(tr("添加置位线圈")); | |||
| add_reset_coil_action_->setToolTip(tr("添加复位线圈")); | |||
| add_compare_action_->setToolTip(tr("添加 D 值与常量比较")); | |||
| delete_logic_action_->setToolTip(tr("删除选中的逻辑节点或连接")); | |||
| add_rung_action_->setToolTip(tr("在梯形图末尾新增网络")); | |||
| parallel_insert_action_->setToolTip(tr("将下一个条件并联到当前选中的条件")); | |||
| add_normally_open_action_->setToolTip(tr("向当前网络添加常开触点")); | |||
| add_normally_closed_action_->setToolTip(tr("向当前网络添加常闭触点")); | |||
| add_normal_coil_action_->setToolTip(tr("设置当前网络的普通线圈")); | |||
| add_set_coil_action_->setToolTip(tr("设置当前网络的置位线圈")); | |||
| add_reset_coil_action_->setToolTip(tr("设置当前网络的复位线圈")); | |||
| add_compare_action_->setToolTip(tr("向当前网络添加 D 值与常量比较")); | |||
| delete_logic_action_->setToolTip(tr("删除选中的逻辑节点或网络")); | |||
| connect(add_rung_action_, &QAction::triggered, | |||
| this, &MainWindow::addLogicRung); | |||
| connect(add_normally_open_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(ContactNodeConfig{ | |||
| addLogicCondition(ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}); | |||
| }); | |||
| @@ -271,7 +283,7 @@ void MainWindow::configureActions() | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(ContactNodeConfig{ | |||
| addLogicCondition(ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyClosed}); | |||
| }); | |||
| @@ -279,7 +291,7 @@ void MainWindow::configureActions() | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CoilNodeConfig{ | |||
| setLogicOutput(CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| CoilMode::Normal}); | |||
| }); | |||
| @@ -287,7 +299,7 @@ void MainWindow::configureActions() | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CoilNodeConfig{ | |||
| setLogicOutput(CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| CoilMode::Set}); | |||
| }); | |||
| @@ -295,7 +307,7 @@ void MainWindow::configureActions() | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CoilNodeConfig{ | |||
| setLogicOutput(CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| CoilMode::Reset}); | |||
| }); | |||
| @@ -303,7 +315,7 @@ void MainWindow::configureActions() | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CompareNodeConfig{ | |||
| addLogicCondition(CompareNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, 0}, | |||
| ComparisonOperator::Equal, | |||
| 0}); | |||
| @@ -435,13 +447,6 @@ void MainWindow::configureLogicEditor() | |||
| { | |||
| showLogicNodeProperties(toUtf8(id)); | |||
| }); | |||
| connect(logic_editor_widget_, &LogicEditorWidget::nodeChanged, | |||
| this, | |||
| [this](const QString &id) | |||
| { | |||
| showLogicNodeProperties(toUtf8(id)); | |||
| refreshProjectUi(); | |||
| }); | |||
| connect(logic_editor_widget_, &LogicEditorWidget::graphChanged, | |||
| this, &MainWindow::refreshProjectUi); | |||
| connect(logic_editor_widget_, &LogicEditorWidget::editorError, | |||
| @@ -802,18 +807,49 @@ void MainWindow::applySelectedControlProperties() | |||
| statusBar()->showMessage(tr("控件属性已更新"), 3000); | |||
| } | |||
| void MainWindow::addLogicNode(const LogicNodeConfig &config) | |||
| void MainWindow::addLogicCondition(const LogicNodeConfig &config) | |||
| { | |||
| const LogicEditorResult result = parallel_insert_action_->isChecked() | |||
| ? logic_editor_widget_->addParallelCondition(config) | |||
| : logic_editor_widget_->appendCondition(config); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("添加逻辑条件"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| parallel_insert_action_->setChecked(false); | |||
| selected_logic_node_id_ = result.id; | |||
| showLogicNodeProperties(result.id); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已添加逻辑条件"), 3000); | |||
| } | |||
| void MainWindow::setLogicOutput(const LogicNodeConfig &config) | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->addNode(config); | |||
| const LogicEditorResult result = logic_editor_widget_->setOutput(config); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("添加逻辑节点"), fromUtf8(result.message), false); | |||
| showProjectResult(tr("设置输出线圈"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_ = result.id; | |||
| showLogicNodeProperties(result.id); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已添加逻辑节点"), 3000); | |||
| statusBar()->showMessage(tr("输出线圈已设置"), 3000); | |||
| } | |||
| void MainWindow::addLogicRung() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->addRung(); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("新建网络"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已新建网络"), 3000); | |||
| } | |||
| void MainWindow::deleteSelectedLogicObject() | |||
| @@ -92,9 +92,13 @@ private: | |||
| void deleteSelectedControl(); | |||
| // 将属性表单内容应用到当前选中控件 | |||
| void applySelectedControlProperties(); | |||
| // 向当前控制逻辑添加指定配置的节点 | |||
| void addLogicNode(const LogicNodeConfig &config); | |||
| // 删除当前选中的逻辑节点或连接 | |||
| // 向当前网络添加串联条件,或按当前插入模式添加并联条件 | |||
| void addLogicCondition(const LogicNodeConfig &config); | |||
| // 设置当前网络右侧的输出线圈 | |||
| void setLogicOutput(const LogicNodeConfig &config); | |||
| // 新增一个梯形图网络 | |||
| void addLogicRung(); | |||
| // 删除当前选中的逻辑节点或网络 | |||
| void deleteSelectedLogicObject(); | |||
| // 将逻辑属性表单内容应用到当前选中节点 | |||
| void applySelectedLogicNodeProperties(); | |||
| @@ -150,6 +154,8 @@ private: | |||
| QAction *add_numeric_display_action_ = nullptr; | |||
| QAction *add_numeric_input_action_ = nullptr; | |||
| QAction *delete_control_action_ = nullptr; | |||
| QAction *add_rung_action_ = nullptr; | |||
| QAction *parallel_insert_action_ = nullptr; | |||
| QAction *add_normally_open_action_ = nullptr; | |||
| QAction *add_normally_closed_action_ = nullptr; | |||
| QAction *add_normal_coil_action_ = nullptr; | |||
| @@ -56,7 +56,7 @@ void testRegisterRepositorySeparatesAreas() | |||
| Project makeValidProject() | |||
| { | |||
| // 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线 | |||
| // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | |||
| HmiControl start_button; | |||
| start_button.id = "start-button"; | |||
| start_button.type = HmiControlType::Button; | |||
| @@ -83,8 +83,15 @@ Project makeValidProject() | |||
| ControlLogic logic; | |||
| logic.id = "start-logic"; | |||
| logic.name = "Start logic"; | |||
| logic.nodes = {contact, coil}; | |||
| logic.connections.push_back({contact.id, coil.id}); | |||
| LadderStage stage; | |||
| stage.id = "stage-1"; | |||
| stage.branches.push_back(contact); | |||
| LadderRung rung; | |||
| rung.id = "rung-1"; | |||
| rung.name = "Network 1"; | |||
| rung.stages.push_back(stage); | |||
| rung.output = coil; | |||
| logic.rungs.push_back(rung); | |||
| Project project; | |||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | |||
| @@ -117,8 +124,14 @@ void testLogicNodeConfigurationBoundaries() | |||
| require(comparison.validate(), "comparison node bound to D address must be valid"); | |||
| } | |||
| void testLogicGraphBoundaries() | |||
| void testLadderLogicBoundaries() | |||
| { | |||
| LogicNode stop; | |||
| stop.id = "stop"; | |||
| stop.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| ContactMode::NormallyClosed}; | |||
| LogicNode start; | |||
| start.id = "start"; | |||
| start.config = ContactNodeConfig{ | |||
| @@ -140,29 +153,34 @@ void testLogicGraphBoundaries() | |||
| ControlLogic logic; | |||
| logic.id = "hold-logic"; | |||
| logic.name = "Hold logic"; | |||
| logic.nodes = {start, run_contact, coil}; | |||
| logic.connections = { | |||
| {start.id, coil.id}, | |||
| {run_contact.id, coil.id}}; | |||
| require(logic.validate(), "a branched self-hold graph must be valid"); | |||
| logic.connections.push_back({start.id, coil.id}); | |||
| require(!logic.validate(), "duplicate logic connections must be rejected"); | |||
| logic.connections.pop_back(); | |||
| logic.connections.push_back({coil.id, start.id}); | |||
| require(!logic.validate(), "coil outgoing connections must be rejected"); | |||
| logic.connections.pop_back(); | |||
| logic.connections.push_back({start.id, run_contact.id}); | |||
| logic.connections.push_back({run_contact.id, start.id}); | |||
| require(!logic.validate(), "logic connection cycles must be rejected"); | |||
| logic.connections.pop_back(); | |||
| logic.connections.pop_back(); | |||
| logic.connections.clear(); | |||
| logic.connections.push_back({start.id, coil.id}); | |||
| require(!logic.validate(), "a disconnected condition node must be rejected"); | |||
| LadderRung rung; | |||
| rung.id = "rung-1"; | |||
| rung.name = "Self hold"; | |||
| rung.stages.push_back({"stage-stop", {stop}}); | |||
| rung.stages.push_back({"stage-start", {start, run_contact}}); | |||
| rung.output = coil; | |||
| logic.rungs.push_back(rung); | |||
| require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid"); | |||
| logic.rungs.front().stages.front().branches.push_back(coil); | |||
| require(!logic.validate(), "a ladder condition stage must reject coils"); | |||
| logic.rungs.front().stages.front().branches.pop_back(); | |||
| logic.rungs.front().output = start; | |||
| require(!logic.validate(), "a ladder output must be a coil"); | |||
| logic.rungs.front().output.reset(); | |||
| require(!logic.validate(), "conditions without an output must fail full validation"); | |||
| LadderRung empty_rung{"rung-empty", "Empty network", {}, std::nullopt}; | |||
| require(empty_rung.validate(), "an empty editing network must be valid"); | |||
| empty_rung.output = coil; | |||
| require(!empty_rung.validate(), "an output without conditions must be rejected"); | |||
| logic.rungs.front().output = coil; | |||
| logic.rungs.front().stages.at(1).branches.at(1).id = start.id; | |||
| require(!logic.validate(), "logic node ids must be unique"); | |||
| } | |||
| void testModelsValidateBindingsAndIdentifiers() | |||
| @@ -225,7 +243,7 @@ int main() | |||
| testRegisterAddressBoundaries(); | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testLogicGraphBoundaries(); | |||
| testLadderLogicBoundaries(); | |||
| testModelsValidateBindingsAndIdentifiers(); | |||
| testRuntimeStateBoundaries(); | |||
| } | |||
| @@ -39,33 +39,53 @@ void testEditorOperations() | |||
| require(logic_result.succeeded, "default logic must be created"); | |||
| const std::string logic_id = logic_result.id; | |||
| const LogicEditorResult start_result = service.addNode( | |||
| const std::string rung_id = service.firstRungId(logic_id); | |||
| require(!rung_id.empty(), "default logic must contain an editable rung"); | |||
| const LogicEditorResult stop_result = service.appendCondition( | |||
| logic_id, | |||
| rung_id, | |||
| ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| ContactMode::NormallyClosed}); | |||
| const LogicEditorResult start_result = service.appendCondition( | |||
| logic_id, | |||
| rung_id, | |||
| ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}, | |||
| {80, 80}); | |||
| const LogicEditorResult coil_result = service.addNode( | |||
| ContactMode::NormallyOpen}); | |||
| require(stop_result.succeeded && start_result.succeeded, | |||
| "series contacts must be appended as ladder stages"); | |||
| const std::string start_stage_id = service.stageIdForNode( | |||
| logic_id, start_result.id); | |||
| const LogicEditorResult hold_result = service.addParallelCondition( | |||
| logic_id, | |||
| rung_id, | |||
| start_stage_id, | |||
| ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 2}, | |||
| ContactMode::NormallyOpen}); | |||
| const LogicEditorResult coil_result = service.setOutput( | |||
| logic_id, | |||
| rung_id, | |||
| CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Normal}, | |||
| {360, 80}); | |||
| require(start_result.succeeded && coil_result.succeeded, | |||
| "contact and coil must be added"); | |||
| require(service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded, | |||
| "valid nodes must connect"); | |||
| require(!service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded, | |||
| "duplicate node connections must fail"); | |||
| require(!service.connectNodes(logic_id, coil_result.id, start_result.id).succeeded, | |||
| "coil outgoing connection must fail"); | |||
| RegisterAddress{RegisterArea::M, 2}, | |||
| CoilMode::Normal}); | |||
| require(hold_result.succeeded && coil_result.succeeded, | |||
| "parallel hold contact and output coil must be added"); | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && rung->stages.size() == 2, | |||
| "series conditions must occupy ordered stages"); | |||
| require(rung->stages.at(1).branches.size() == 2, | |||
| "parallel conditions must share one stage"); | |||
| require(rung->output.has_value(), "rung output must be fixed separately"); | |||
| require(service.findLogic(logic_id)->validate(), | |||
| "configured self-hold ladder must pass full validation"); | |||
| const LogicNode *start = service.findNode(logic_id, start_result.id); | |||
| require(start != nullptr && start->position.x == 80, | |||
| "added node position must be stored"); | |||
| require(service.moveNode(logic_id, start_result.id, {120, 160}).succeeded, | |||
| "node position must be editable"); | |||
| require(start != nullptr, "added condition must be discoverable"); | |||
| require(service.updateNodeConfig( | |||
| logic_id, | |||
| start_result.id, | |||
| @@ -75,12 +95,21 @@ void testEditorOperations() | |||
| .succeeded, | |||
| "contact properties must be editable"); | |||
| require(service.removeNode(logic_id, hold_result.id).succeeded, | |||
| "parallel branch deletion must succeed"); | |||
| require(service.findRung(logic_id, rung_id)->stages.at(1).branches.size() == 1, | |||
| "deleting one branch must keep the ladder stage"); | |||
| require(service.removeNode(logic_id, start_result.id).succeeded, | |||
| "node deletion must succeed"); | |||
| require(service.findNode(logic_id, start_result.id) == nullptr, | |||
| "deleted node must disappear"); | |||
| require(service.findLogic(logic_id)->connections.empty(), | |||
| "deleting a node must remove its connections"); | |||
| "last branch deletion must succeed"); | |||
| require(service.findRung(logic_id, rung_id)->stages.size() == 1, | |||
| "deleting the last branch must remove the empty stage"); | |||
| const LogicEditorResult second_rung = service.addRung(logic_id); | |||
| require(second_rung.succeeded, "additional ladder rungs must be supported"); | |||
| require(service.removeRung(logic_id, second_rung.id).succeeded, | |||
| "additional ladder rungs must be removable"); | |||
| require(!service.removeRung(logic_id, rung_id).succeeded, | |||
| "the only remaining ladder rung must not be removed"); | |||
| } | |||
| } // namespace | |||
| @@ -82,6 +82,8 @@ void testModeActionsControlEditingAvailability() | |||
| window, "addNormallyOpenAction"); | |||
| QAction *add_normal_coil_action = requiredChild<QAction>( | |||
| window, "addNormalCoilAction"); | |||
| QAction *parallel_insert_action = requiredChild<QAction>( | |||
| window, "parallelInsertAction"); | |||
| QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock"); | |||
| QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | |||
| QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | |||
| @@ -111,12 +113,19 @@ void testModeActionsControlEditingAvailability() | |||
| require(editor_service.findPage(page_id)->controls.empty(), | |||
| "deleting a selected control must update the HMI page model"); | |||
| add_normally_open_action->trigger(); | |||
| parallel_insert_action->trigger(); | |||
| add_normally_open_action->trigger(); | |||
| add_normal_coil_action->trigger(); | |||
| const ControlLogic *logic = logic_editor_service.findLogic( | |||
| logic_editor_service.firstLogicId()); | |||
| require(logic != nullptr && logic->nodes.size() == 2, | |||
| "logic actions must add nodes through the logic editor service"); | |||
| require(logic != nullptr && logic->rungs.size() == 1, | |||
| "logic actions must edit the default ladder rung"); | |||
| require(logic->rungs.front().stages.size() == 1 | |||
| && logic->rungs.front().stages.front().branches.size() == 2, | |||
| "parallel insert mode must add a branch to the selected stage"); | |||
| require(logic->rungs.front().output.has_value(), | |||
| "coil action must set the fixed ladder output"); | |||
| offline_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | |||
| @@ -22,7 +22,7 @@ void require(bool condition, const std::string &message) | |||
| Project makeExampleProject() | |||
| { | |||
| // 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本 | |||
| // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本 | |||
| HmiControl start_button; | |||
| start_button.id = "start-button"; | |||
| start_button.type = HmiControlType::Button; | |||
| @@ -68,7 +68,6 @@ Project makeExampleProject() | |||
| contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| contact.position = {80, 100}; | |||
| LogicNode compare; | |||
| compare.id = "temperature-check"; | |||
| @@ -76,22 +75,30 @@ Project makeExampleProject() | |||
| RegisterAddress{RegisterArea::D, 2}, | |||
| ComparisonOperator::GreaterThanOrEqual, | |||
| static_cast<std::int16_t>(100)}; | |||
| compare.position = {320, 100}; | |||
| LogicNode hold_contact; | |||
| hold_contact.id = "hold-contact"; | |||
| hold_contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| ContactMode::NormallyOpen}; | |||
| LogicNode coil; | |||
| coil.id = "run-coil"; | |||
| coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Set}; | |||
| coil.position = {560, 100}; | |||
| ControlLogic logic; | |||
| logic.id = "start-logic"; | |||
| logic.name = "Start logic"; | |||
| logic.enabled = false; | |||
| logic.nodes = {contact, compare, coil}; | |||
| logic.connections.push_back({contact.id, compare.id}); | |||
| logic.connections.push_back({compare.id, coil.id}); | |||
| LadderRung rung; | |||
| rung.id = "rung-1"; | |||
| rung.name = "Network 1"; | |||
| rung.stages.push_back({"stage-1", {contact, hold_contact}}); | |||
| rung.stages.push_back({"stage-2", {compare}}); | |||
| rung.output = coil; | |||
| logic.rungs.push_back(rung); | |||
| Project project; | |||
| project.metadata = {"example-project", "Example project", "1.0"}; | |||
| @@ -154,6 +161,15 @@ void testExampleProjectRoundTrip() | |||
| const QString second_path = directory.filePath("example-copy.json"); | |||
| require(service.saveAs(first_path.toStdString()).succeeded, | |||
| "example project save must succeed"); | |||
| const QByteArray saved_json = readBytes(first_path); | |||
| require(saved_json.contains("\"rungs\"") | |||
| && saved_json.contains("\"stages\"") | |||
| && saved_json.contains("\"branches\"") | |||
| && saved_json.contains("\"output\""), | |||
| "saved logic must use the ladder hierarchy"); | |||
| require(!saved_json.contains("\"position\"") | |||
| && !saved_json.contains("\"connections\""), | |||
| "saved ladder logic must not contain free-graph fields"); | |||
| require(service.load(first_path.toStdString()).succeeded, | |||
| "example project load must succeed"); | |||
| @@ -177,13 +193,16 @@ void testExampleProjectRoundTrip() | |||
| "control logic count must survive round trip"); | |||
| require(!project.controlLogics.front().enabled, | |||
| "control logic enabled state must survive round trip"); | |||
| require(project.controlLogics.front().nodes.size() == 3, | |||
| "logic node count must survive round trip"); | |||
| require(project.controlLogics.front().nodes.front().position.x == 80, | |||
| "logic node position must survive round trip"); | |||
| const LadderRung &rung = project.controlLogics.front().rungs.front(); | |||
| require(rung.stages.size() == 2, | |||
| "ladder stage count must survive round trip"); | |||
| require(rung.stages.front().branches.size() == 2, | |||
| "parallel ladder branches must survive round trip"); | |||
| require(rung.output.has_value(), | |||
| "ladder output must survive round trip"); | |||
| const auto &compare = std::get<CompareNodeConfig>( | |||
| project.controlLogics.front().nodes.at(1).config); | |||
| rung.stages.at(1).branches.front().config); | |||
| require(compare.address.index() == 2 && compare.value == 100, | |||
| "comparison configuration must survive round trip"); | |||
| @@ -86,12 +86,11 @@ | |||
| - 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。 | |||
| - 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。 | |||
| - `HmiRuntimeService` 仅通过 `RegisterRepository` 访问寄存器,不依赖虚拟寄存器、串口或 Modbus。 | |||
| - 控制逻辑使用有向无环图;串联为 AND、多个前驱汇合为 OR、一个输出连接多个后继为并行分支。 | |||
| - 线圈只能作为终点,保存时要求线圈有输入且每个条件节点最终到达线圈。 | |||
| - 状态保持通过触点与线圈共享 M 地址表达,不创建图上的反馈环。 | |||
| - `LogicEditorService` 负责节点和连接编辑,删除节点会同时删除关联连接。 | |||
| - `LogicEditorWidget` 只保存节点 ID 和连接端点,不访问 HMI、寄存器或 PLC。 | |||
| - 节点位置保存在 `1.0` 工程文件中,并作为逻辑节点的必填工程字段。 | |||
| - 控制逻辑使用梯形图层级:逻辑包含网络,网络包含串联条件级,条件级内部包含并联条件,输出线圈固定在右侧。 | |||
| - 条件级之间执行 AND,同级并联条件执行 OR;状态保持由常闭停止与“常开启动 OR 常开运行反馈”串联后驱动运行线圈。 | |||
| - `LogicEditorService` 负责网络、串联条件、并联条件、输出线圈、属性和删除操作。 | |||
| - `LogicEditorWidget` 自动布局母线、支路和节点,不允许自由拖动或手动画线,不保存节点坐标。 | |||
| - 控制逻辑编辑器不访问 HMI、寄存器仓库或 PLC,工程格式版本保持 `1.0`。 | |||
| - Validation run and results: | |||
| - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | |||
| - 已启动 `integrated_platform.exe` 并正常退出。 | |||
| @@ -115,6 +114,7 @@ | |||
| - 控制逻辑编辑器完成后,领域、逻辑编辑服务、工程管理和主窗口测试全部通过。 | |||
| - Debug 应用构建成功。 | |||
| - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 | |||
| - 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。 | |||
| - Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。 | |||
| - Known risks / blockers: 无。 | |||
| - Suggested next command: 为控制逻辑建立固定周期的软件执行器并按拓扑顺序求值。 | |||
| - Suggested next command: 为控制逻辑建立固定周期的软件执行器,按网络顺序执行并计算级间 AND 与级内 OR。 | |||
| @@ -60,7 +60,7 @@ main.cpp -> UI + Services + Infrastructure | |||
| | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | |||
| | `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 | | |||
| | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定,并校验控件位于页面边界内。 | | |||
| | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | |||
| | `control_logic_model.*` | 定义网络、串联级、并联条件和输出线圈,并校验节点类型及地址区域。 | | |||
| | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | |||
| | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | |||
| @@ -84,15 +84,15 @@ main.cpp -> UI + Services + Infrastructure | |||
| 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | |||
| 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | |||
| 控制逻辑使用有向无环图表达。没有前驱的条件节点从隐式左母线取得真值,条件节点输出为 | |||
| 前驱输入与自身条件的逻辑与;多个前驱在汇合处执行逻辑或,一个输出连接多个后继表示并行 | |||
| 分支。线圈只能作为终点。状态保持通过触点和线圈绑定同一个 M 地址表达,不建立从线圈返回 | |||
| 触点的环形连接。上述语义是后续软件逻辑执行器的确定性求值契约。 | |||
| 控制逻辑按标准梯形图的受限结构表达:一个逻辑包含多个网络,一个网络包含按顺序串联的多个 | |||
| 条件级,每个条件级包含一个或多个并联条件,输出线圈固定在网络最右侧。条件级之间执行逻辑与, | |||
| 同级并联条件执行逻辑或。状态保持通过启动触点和运行反馈触点并联,并与停止触点串联后驱动运行 | |||
| 线圈表达。该结构是后续软件逻辑执行器的确定性求值契约,不需要通用图遍历或连接表。 | |||
| `services/LogicEditorService` 负责逻辑节点的添加、移动、属性更新、连接、断开和删除。编辑服务 | |||
| 只要求每次操作后的结构合法,允许用户暂时保留尚未连接完成的中间状态;工程保存仍调用完整 | |||
| 领域校验,拒绝无输入线圈和不能到达线圈的条件节点。`ui/LogicEditorWidget` 只投影节点标识和 | |||
| 连接端点,通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。节点画布位置随工程保存。 | |||
| `services/LogicEditorService` 负责新增和删除网络、追加串联条件、增加并联条件、设置输出线圈、 | |||
| 修改属性和删除节点。空网络可作为编辑占位;非空网络保存时必须同时具备条件和输出线圈。 | |||
| `ui/LogicEditorWidget` 自动排列左右母线、串联级、并联支路和右侧线圈,不允许自由拖动或手动画线, | |||
| 也不保存像素坐标。编辑器通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。 | |||
| ## 运行模式边界 | |||
| @@ -53,11 +53,12 @@ | |||
| ## 7. 实现控制逻辑编辑器 | |||
| - 建立图形逻辑模型,支持基本连接、删除和属性配置。 | |||
| - 建立梯形图逻辑模型,支持网络、串联条件、并联支路、输出线圈、删除和属性配置。 | |||
| - 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。 | |||
| - 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。 | |||
| - 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。 | |||
| - 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 | |||
| - 画布按梯形图结构自动布局,不允许节点自由拖动,也不要求用户手动画线。 | |||
| 完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 | |||