| @@ -1,12 +1,9 @@ | |||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <unordered_map> | |||||
| #include <unordered_set> | |||||
| namespace { | namespace { | ||||
| // 在提供错误字符串时写入校验失败原因 | |||||
| void setError(std::string *error, const std::string &message) | void setError(std::string *error, const std::string &message) | ||||
| { | { | ||||
| if (error != nullptr) | 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) | bool validateConfig(const ContactNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | 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"); | setError(error, "contact node requires a valid M address"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (!isSupportedMode(config.mode)) | |||||
| if (config.mode != ContactMode::NormallyOpen | |||||
| && config.mode != ContactMode::NormallyClosed) | |||||
| { | { | ||||
| setError(error, "contact node has an unsupported mode"); | setError(error, "contact node has an unsupported mode"); | ||||
| return false; | return false; | ||||
| @@ -54,7 +28,6 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||||
| return true; | return true; | ||||
| } | } | ||||
| // 校验线圈节点配置 | |||||
| bool validateConfig(const CoilNodeConfig &config, std::string *error) | bool validateConfig(const CoilNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | 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"); | setError(error, "coil node requires a valid M address"); | ||||
| return false; | 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"); | setError(error, "coil node has an unsupported mode"); | ||||
| return false; | return false; | ||||
| @@ -70,7 +45,6 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error) | |||||
| return true; | return true; | ||||
| } | } | ||||
| // 校验数值比较节点配置 | |||||
| bool validateConfig(const CompareNodeConfig &config, std::string *error) | bool validateConfig(const CompareNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::D) | 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"); | setError(error, "comparison node requires a valid D address"); | ||||
| return false; | 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"); | setError(error, "comparison node has an unsupported operator"); | ||||
| return false; | return false; | ||||
| @@ -86,14 +65,27 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||||
| return true; | 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 | bool LogicNode::validate(std::string *error) const | ||||
| { | { | ||||
| if (id.empty()) | if (id.empty()) | ||||
| @@ -101,220 +93,166 @@ bool LogicNode::validate(std::string *error) const | |||||
| setError(error, "logic node id must not be empty"); | setError(error, "logic node id must not be empty"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (!position.isValid()) | |||||
| { | |||||
| setError(error, "logic node position is outside the supported range"); | |||||
| return false; | |||||
| } | |||||
| // 根据 variant 中实际保存的配置类型调用对应校验函数 | |||||
| return std::visit( | return std::visit( | ||||
| [error](const auto &nodeConfig) | |||||
| [error](const auto &config) | |||||
| { | { | ||||
| return validateConfig(nodeConfig, error); | |||||
| return validateConfig(config, error); | |||||
| }, | }, | ||||
| config); | 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; | 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; | 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; | 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 false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| bool ControlLogic::validateStructure(std::string *error) const | |||||
| bool LadderRung::validateStructure(std::string *error) const | |||||
| { | { | ||||
| if (id.empty() || name.empty()) | 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; | return false; | ||||
| } | } | ||||
| // 校验所有节点并收集节点标识用于后续检查连接引用 | |||||
| std::vector<std::string> node_ids; | 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; | 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; | 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; | 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; | 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 false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -3,122 +3,93 @@ | |||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <optional> | |||||
| #include <string> | #include <string> | ||||
| #include <variant> | #include <variant> | ||||
| #include <vector> | #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 | enum class ContactMode | ||||
| { | { | ||||
| NormallyOpen, // 常开触点 | |||||
| NormallyClosed // 常闭触点 | |||||
| NormallyOpen, | |||||
| NormallyClosed | |||||
| }; | }; | ||||
| // 线圈工作方式 | |||||
| enum class CoilMode | enum class CoilMode | ||||
| { | { | ||||
| Normal, // 普通线圈 | |||||
| Set, // 置位线圈 | |||||
| Reset // 复位线圈 | |||||
| Normal, | |||||
| Set, | |||||
| Reset | |||||
| }; | }; | ||||
| // 数值比较运算符 | |||||
| enum class ComparisonOperator | enum class ComparisonOperator | ||||
| { | { | ||||
| Equal, // 等于 | |||||
| NotEqual, // 不等于 | |||||
| LessThan, // 小于 | |||||
| LessThanOrEqual, // 小于等于 | |||||
| GreaterThan, // 大于 | |||||
| GreaterThanOrEqual // 大于等于 | |||||
| Equal, | |||||
| NotEqual, | |||||
| LessThan, | |||||
| LessThanOrEqual, | |||||
| GreaterThan, | |||||
| GreaterThanOrEqual | |||||
| }; | }; | ||||
| // 描述读取 M 区位状态的触点节点配置 | |||||
| struct ContactNodeConfig | struct ContactNodeConfig | ||||
| { | { | ||||
| // 触点绑定的 M 区地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| // 触点工作方式 | |||||
| ContactMode mode = ContactMode::NormallyOpen; | ContactMode mode = ContactMode::NormallyOpen; | ||||
| }; | }; | ||||
| // 描述向 M 区写入状态的线圈节点配置 | |||||
| struct CoilNodeConfig | struct CoilNodeConfig | ||||
| { | { | ||||
| // 线圈绑定的 M 区地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| // 线圈工作方式 | |||||
| CoilMode mode = CoilMode::Normal; | CoilMode mode = CoilMode::Normal; | ||||
| }; | }; | ||||
| // 描述 D 区数值与固定值的比较节点配置 | |||||
| struct CompareNodeConfig | struct CompareNodeConfig | ||||
| { | { | ||||
| // 被比较的 D 区地址 | |||||
| RegisterAddress address{RegisterArea::D, 0}; | RegisterAddress address{RegisterArea::D, 0}; | ||||
| // 比较运算符 | |||||
| ComparisonOperator comparison = ComparisonOperator::Equal; | ComparisonOperator comparison = ComparisonOperator::Equal; | ||||
| // 参与比较的固定值 | |||||
| std::int16_t value = 0; | std::int16_t value = 0; | ||||
| }; | }; | ||||
| // 控制逻辑节点支持的配置类型 | |||||
| using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | ||||
| // 描述控制逻辑中的一个功能节点 | |||||
| struct LogicNode | struct LogicNode | ||||
| { | { | ||||
| // 节点唯一标识 | |||||
| std::string id; | std::string id; | ||||
| // 节点自身的业务配置 | |||||
| LogicNodeConfig config; | LogicNodeConfig config; | ||||
| // 节点在逻辑编辑画布中的位置 | |||||
| LogicPoint position; | |||||
| // 校验节点配置并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | 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; | 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 | struct ControlLogic | ||||
| { | { | ||||
| // 控制逻辑唯一标识 | |||||
| std::string id; | std::string id; | ||||
| // 控制逻辑显示名称 | |||||
| std::string name; | std::string name; | ||||
| // 控制逻辑包含的节点集合 | |||||
| std::vector<LogicNode> nodes; | |||||
| // 控制逻辑包含的连接集合 | |||||
| std::vector<LogicConnection> connections; | |||||
| // 控制逻辑是否启用 | |||||
| std::vector<LadderRung> rungs; | |||||
| bool enabled = true; | bool enabled = true; | ||||
| // 校验控制逻辑并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| // 校验编辑过程所需的结构规则,允许暂时没有完整输出 | |||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| @@ -211,38 +211,6 @@ QJsonObject serializeAddress(const RegisterAddress &address) | |||||
| return object; | 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( | bool parseAddress( | ||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| @@ -751,7 +719,6 @@ QJsonObject serializeLogicNode(const LogicNode &node) | |||||
| { | { | ||||
| QJsonObject object; | QJsonObject object; | ||||
| object.insert(QStringLiteral("id"), fromUtf8(node.id)); | object.insert(QStringLiteral("id"), fromUtf8(node.id)); | ||||
| object.insert(QStringLiteral("position"), serializeLogicPoint(node.position)); | |||||
| // std::visit 将不同节点配置统一转换为 config JSON 对象 | // std::visit 将不同节点配置统一转换为 config JSON 对象 | ||||
| object.insert( | object.insert( | ||||
| QStringLiteral("config"), | QStringLiteral("config"), | ||||
| @@ -847,124 +814,184 @@ bool parseLogicNode( | |||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonObject config; | QJsonObject config; | ||||
| QJsonObject position; | |||||
| if (!readString(object, "id", context, &node->id, state) | if (!readString(object, "id", context, &node->id, state) | ||||
| || !readObject(object, "config", context, &config, state) | || !readObject(object, "config", context, &config, state) | ||||
| || !readObject(object, "position", context, &position, state) | |||||
| || !parseNodeConfig(config, context + ".config", &node->config, state)) | || !parseNodeConfig(config, context + ".config", &node->config, state)) | ||||
| { | { | ||||
| return false; | 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; | 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; | return object; | ||||
| } | } | ||||
| // 解析逻辑连接的起点和终点节点标识 | |||||
| bool parseConnection( | |||||
| bool parseLadderStage( | |||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| LogicConnection *connection, | |||||
| LadderStage *stage, | |||||
| ParseState *state) | 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; | QJsonObject object; | ||||
| object.insert(QStringLiteral("id"), fromUtf8(logic.id)); | object.insert(QStringLiteral("id"), fromUtf8(logic.id)); | ||||
| object.insert(QStringLiteral("name"), fromUtf8(logic.name)); | object.insert(QStringLiteral("name"), fromUtf8(logic.name)); | ||||
| object.insert(QStringLiteral("enabled"), logic.enabled); | object.insert(QStringLiteral("enabled"), logic.enabled); | ||||
| object.insert(QStringLiteral("nodes"), nodes); | |||||
| object.insert(QStringLiteral("connections"), connections); | |||||
| object.insert(QStringLiteral("rungs"), rungs); | |||||
| return object; | return object; | ||||
| } | } | ||||
| // 解析一套控制逻辑,并逐项构造节点和连接集合 | |||||
| bool parseControlLogic( | bool parseControlLogic( | ||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| ControlLogic *logic, | ControlLogic *logic, | ||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonArray nodes; | |||||
| QJsonArray connections; | |||||
| QJsonArray rungs; | |||||
| if (!readString(object, "id", context, &logic->id, state) | if (!readString(object, "id", context, &logic->id, state) | ||||
| || !readString(object, "name", context, &logic->name, state) | || !readString(object, "name", context, &logic->name, state) | ||||
| || !readBool(object, "enabled", context, &logic->enabled, 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; | 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( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | 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; | return false; | ||||
| } | } | ||||
| logic->connections.push_back(std::move(connection)); | |||||
| logic->rungs.push_back(std::move(rung)); | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -9,17 +9,12 @@ | |||||
| namespace { | 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 | } // namespace | ||||
| @@ -31,15 +26,33 @@ LogicEditorService::LogicEditorService(ProjectService &project_service) | |||||
| const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const | 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( | const auto logic = std::find_if( | ||||
| project.controlLogics.cbegin(), | |||||
| project.controlLogics.cend(), | |||||
| logics.cbegin(), | |||||
| logics.cend(), | |||||
| [&logic_id](const ControlLogic &candidate) | [&logic_id](const ControlLogic &candidate) | ||||
| { | { | ||||
| return candidate.id == logic_id; | 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( | const LogicNode *LogicEditorService::findNode( | ||||
| @@ -50,20 +63,99 @@ const LogicNode *LogicEditorService::findNode( | |||||
| { | { | ||||
| return nullptr; | 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 | 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() | LogicEditorResult LogicEditorService::ensureDefaultLogic() | ||||
| @@ -72,33 +164,25 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic() | |||||
| { | { | ||||
| return {true, LogicEditorError::None, {}, firstLogicId()}; | return {true, LogicEditorError::None, {}, firstLogicId()}; | ||||
| } | } | ||||
| ControlLogic logic; | ControlLogic logic; | ||||
| logic.id = "logic-1"; | logic.id = "logic-1"; | ||||
| logic.name = "控制逻辑 1"; | logic.name = "控制逻辑 1"; | ||||
| logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt}); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.controlLogics.push_back(std::move(logic)); | project.controlLogics.push_back(std::move(logic)); | ||||
| return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | 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); | const ControlLogic *logic = findLogic(logic_id); | ||||
| if (logic == nullptr) | if (logic == nullptr) | ||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | 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(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.controlLogics.begin(), | project.controlLogics.begin(), | ||||
| @@ -107,230 +191,266 @@ LogicEditorResult LogicEditorService::addNode( | |||||
| { | { | ||||
| return candidate.id == logic_id; | 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 ControlLogic *logic = findLogic(logic_id); | ||||
| const LogicNode *node = findNode(logic_id, node_id); | |||||
| if (logic == nullptr) | if (logic == nullptr) | ||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | 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(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | 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 &logic_id, | ||||
| const std::string &node_id, | |||||
| const std::string &rung_id, | |||||
| const LogicNodeConfig &config) | const LogicNodeConfig &config) | ||||
| { | { | ||||
| const ControlLogic *logic = findLogic(logic_id); | 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) | if (logic == nullptr) | ||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | 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; | std::string error; | ||||
| if (!validateCandidateNode(node_id, config, node->position, &error)) | |||||
| if (!node.validate(&error)) | |||||
| { | { | ||||
| return failure(LogicEditorError::InvalidNode, error); | return failure(LogicEditorError::InvalidNode, error); | ||||
| } | } | ||||
| LadderStage stage; | |||||
| stage.id = makeUniqueStageId(*rung); | |||||
| stage.branches.push_back(std::move(node)); | |||||
| Project &project = project_service_.editProject(); | 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}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| LogicEditorResult LogicEditorService::connectNodes( | |||||
| LogicEditorResult LogicEditorService::addParallelCondition( | |||||
| const std::string &logic_id, | 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 ControlLogic *logic = findLogic(logic_id); | ||||
| const LadderRung *rung = findRung(logic_id, rung_id); | |||||
| if (logic == nullptr) | if (logic == nullptr) | ||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | 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; | 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(); | 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 &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); | const ControlLogic *logic = findLogic(logic_id); | ||||
| if (logic == nullptr) | if (logic == nullptr) | ||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | 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(); | 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( | LogicEditorResult LogicEditorService::removeNode( | ||||
| const std::string &logic_id, const std::string &node_id) | 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) | if (findNode(logic_id, node_id) == nullptr) | ||||
| { | { | ||||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | ||||
| } | } | ||||
| Project &project = project_service_.editProject(); | 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}; | 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) | std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | ||||
| { | { | ||||
| return std::visit( | return std::visit( | ||||
| @@ -341,11 +461,14 @@ std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | |||||
| { | { | ||||
| return "contact"; | return "contact"; | ||||
| } | } | ||||
| if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||||
| else if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||||
| { | { | ||||
| return "coil"; | return "coil"; | ||||
| } | } | ||||
| return "compare"; | |||||
| else | |||||
| { | |||||
| return "compare"; | |||||
| } | |||||
| }, | }, | ||||
| config); | config); | ||||
| } | } | ||||
| @@ -355,37 +478,51 @@ std::string LogicEditorService::makeUniqueNodeId( | |||||
| { | { | ||||
| for (std::size_t index = 1;; ++index) | 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; | 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( | LogicEditorResult LogicEditorService::failure( | ||||
| @@ -10,10 +10,11 @@ enum class LogicEditorError | |||||
| { | { | ||||
| None, | None, | ||||
| LogicNotFound, | LogicNotFound, | ||||
| RungNotFound, | |||||
| StageNotFound, | |||||
| NodeNotFound, | NodeNotFound, | ||||
| DuplicateConnection, | |||||
| InvalidNode, | InvalidNode, | ||||
| InvalidConnection, | |||||
| InvalidOperation, | |||||
| UnsupportedNodeChange | UnsupportedNodeChange | ||||
| }; | }; | ||||
| @@ -31,42 +32,49 @@ public: | |||||
| explicit LogicEditorService(ProjectService &project_service); | explicit LogicEditorService(ProjectService &project_service); | ||||
| const ControlLogic *findLogic(const std::string &logic_id) const; | 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 LogicNode *findNode( | ||||
| const std::string &logic_id, const std::string &node_id) const; | const std::string &logic_id, const std::string &node_id) const; | ||||
| std::string firstLogicId() 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 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 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 &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( | LogicEditorResult updateNodeConfig( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &node_id, | const std::string &node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| 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( | LogicEditorResult removeNode( | ||||
| const std::string &logic_id, const std::string &node_id); | const std::string &logic_id, const std::string &node_id); | ||||
| private: | private: | ||||
| static bool isConditionConfig(const LogicNodeConfig &config); | |||||
| static bool isOutputConfig(const LogicNodeConfig &config); | |||||
| static std::string nodePrefix(const LogicNodeConfig &config); | static std::string nodePrefix(const LogicNodeConfig &config); | ||||
| static std::string makeUniqueNodeId( | static std::string makeUniqueNodeId( | ||||
| const ControlLogic &logic, const std::string &prefix); | 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( | static LogicEditorResult failure( | ||||
| LogicEditorError error, const std::string &message); | LogicEditorError error, const std::string &message); | ||||
| @@ -1,95 +1,68 @@ | |||||
| #include "logic_editor_widget.h" | #include "logic_editor_widget.h" | ||||
| #include "services/logic_editor_service.h" | |||||
| #include <QGraphicsPathItem> | |||||
| #include <QGraphicsItem> | |||||
| #include <QGraphicsScene> | #include <QGraphicsScene> | ||||
| #include <QGraphicsSceneMouseEvent> | |||||
| #include <QMouseEvent> | |||||
| #include <QPainter> | #include <QPainter> | ||||
| #include <QResizeEvent> | #include <QResizeEvent> | ||||
| #include <QStyleOptionGraphicsItem> | #include <QStyleOptionGraphicsItem> | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cmath> | #include <cmath> | ||||
| #include <functional> | |||||
| #include <map> | |||||
| #include <type_traits> | #include <type_traits> | ||||
| #include <utility> | |||||
| namespace { | 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( | return std::visit( | ||||
| [](const auto &value) -> QString | [](const auto &value) -> QString | ||||
| { | { | ||||
| using Config = std::decay_t<decltype(value)>; | 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()); | return QStringLiteral("M%1").arg(value.address.index()); | ||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| return QStringLiteral("D%1 = %2") | |||||
| return QStringLiteral("D%1 %2 %3") | |||||
| .arg(value.address.index()) | .arg(value.address.index()) | ||||
| .arg(comparisonText(value.comparison)) | |||||
| .arg(value.value); | .arg(value.value); | ||||
| } | } | ||||
| }, | }, | ||||
| config); | 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 | } // namespace | ||||
| @@ -99,22 +72,21 @@ class LogicEditorWidget::NodeItem final : public QGraphicsItem | |||||
| public: | public: | ||||
| NodeItem( | NodeItem( | ||||
| const LogicNode &node, | 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), | : 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(ItemIsSelectable, true); | ||||
| setFlag(ItemSendsGeometryChanges, true); | |||||
| setAcceptedMouseButtons(Qt::LeftButton); | |||||
| } | } | ||||
| QRectF boundingRect() const override | QRectF boundingRect() const override | ||||
| { | { | ||||
| return {0, 0, kNodeWidth, kNodeHeight}; | |||||
| return {-kNodeWidth / 2.0, -27.0, kNodeWidth, 54.0}; | |||||
| } | } | ||||
| void paint( | void paint( | ||||
| @@ -122,135 +94,115 @@ public: | |||||
| const QStyleOptionGraphicsItem *option, | const QStyleOptionGraphicsItem *option, | ||||
| QWidget *) override | QWidget *) override | ||||
| { | { | ||||
| const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); | |||||
| painter->setRenderHint(QPainter::Antialiasing, true); | 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: | private: | ||||
| std::string node_id_; | std::string node_id_; | ||||
| std::string rung_id_; | |||||
| std::string stage_id_; | |||||
| LogicNodeConfig config_; | 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: | 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); | 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: | 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( | LogicEditorWidget::LogicEditorWidget( | ||||
| @@ -259,12 +211,11 @@ LogicEditorWidget::LogicEditorWidget( | |||||
| : QGraphicsView(parent), editor_service_(editor_service) | : QGraphicsView(parent), editor_service_(editor_service) | ||||
| { | { | ||||
| scene_ = new QGraphicsScene(this); | scene_ = new QGraphicsScene(this); | ||||
| scene_->setSceneRect(0, 0, 2400, 1400); | |||||
| setScene(scene_); | setScene(scene_); | ||||
| setRenderHint(QPainter::Antialiasing, true); | setRenderHint(QPainter::Antialiasing, true); | ||||
| setBackgroundBrush(QColor(QStringLiteral("#ffffff"))); | setBackgroundBrush(QColor(QStringLiteral("#ffffff"))); | ||||
| setDragMode(QGraphicsView::RubberBandDrag); | setDragMode(QGraphicsView::RubberBandDrag); | ||||
| setTransformationAnchor(QGraphicsView::AnchorUnderMouse); | |||||
| setAlignment(Qt::AlignLeft | Qt::AlignTop); | |||||
| connect(scene_, &QGraphicsScene::selectionChanged, | connect(scene_, &QGraphicsScene::selectionChanged, | ||||
| this, &LogicEditorWidget::handleSelectionChanged); | this, &LogicEditorWidget::handleSelectionChanged); | ||||
| } | } | ||||
| @@ -276,75 +227,124 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id) | |||||
| return; | return; | ||||
| } | } | ||||
| logic_id_ = logic_id; | logic_id_ = logic_id; | ||||
| current_rung_id_.clear(); | |||||
| reloadLogic(); | reloadLogic(); | ||||
| } | } | ||||
| void LogicEditorWidget::setEditingEnabled(bool enabled) | void LogicEditorWidget::setEditingEnabled(bool enabled) | ||||
| { | { | ||||
| editing_enabled_ = 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() | void LogicEditorWidget::reloadLogic() | ||||
| { | { | ||||
| clearPreview(); | |||||
| scene_->clear(); | scene_->clear(); | ||||
| if (logic_id_.empty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const ControlLogic *logic = editor_service_.findLogic(logic_id_); | const ControlLogic *logic = editor_service_.findLogic(logic_id_); | ||||
| if (logic == nullptr) | if (logic == nullptr) | ||||
| { | { | ||||
| scene_->setSceneRect(0, 0, kMinimumSceneWidth, 400); | |||||
| return; | 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) | void LogicEditorWidget::selectNode(const std::string &node_id) | ||||
| @@ -376,249 +376,182 @@ std::string LogicEditorWidget::selectedNodeId() const | |||||
| return {}; | 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()) | for (QGraphicsItem *item : scene_->selectedItems()) | ||||
| { | { | ||||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||||
| const NodeItem *node = dynamic_cast<const NodeItem *>(item); | |||||
| if (node != nullptr) | 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) | void LogicEditorWidget::reportFailure(const LogicEditorResult &result) | ||||
| { | { | ||||
| emit editorError(QString::fromStdString(result.message)); | 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 | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "services/logic_editor_service.h" | |||||
| #include <QGraphicsView> | #include <QGraphicsView> | ||||
| #include <string> | #include <string> | ||||
| class LogicEditorService; | |||||
| struct LogicEditorResult; | |||||
| class QGraphicsPathItem; | |||||
| class QGraphicsScene; | class QGraphicsScene; | ||||
| class QMouseEvent; | |||||
| class QResizeEvent; | class QResizeEvent; | ||||
| class LogicEditorWidget final : public QGraphicsView | class LogicEditorWidget final : public QGraphicsView | ||||
| @@ -27,38 +24,34 @@ public: | |||||
| void reloadLogic(); | void reloadLogic(); | ||||
| void selectNode(const std::string &node_id); | void selectNode(const std::string &node_id); | ||||
| std::string selectedNodeId() const; | 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(); | LogicEditorResult deleteSelected(); | ||||
| signals: | signals: | ||||
| void nodeSelected(const QString &node_id); | void nodeSelected(const QString &node_id); | ||||
| void nodeChanged(const QString &node_id); | |||||
| void graphChanged(); | void graphChanged(); | ||||
| void editorError(const QString &message); | void editorError(const QString &message); | ||||
| protected: | protected: | ||||
| void mousePressEvent(QMouseEvent *event) override; | |||||
| void mouseMoveEvent(QMouseEvent *event) override; | |||||
| void mouseReleaseEvent(QMouseEvent *event) override; | |||||
| void resizeEvent(QResizeEvent *event) override; | void resizeEvent(QResizeEvent *event) override; | ||||
| private: | private: | ||||
| class NodeItem; | class NodeItem; | ||||
| class ConnectionItem; | |||||
| class RungItem; | |||||
| void handleSelectionChanged(); | 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); | void reportFailure(const LogicEditorResult &result); | ||||
| std::string currentRungId() const; | |||||
| LogicEditorService &editor_service_; | LogicEditorService &editor_service_; | ||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| std::string logic_id_; | std::string logic_id_; | ||||
| std::string current_rung_id_; | |||||
| bool editing_enabled_ = true; | 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_ = addToolBar(tr("控制逻辑节点")); | ||||
| logic_tool_bar_->setObjectName(QStringLiteral("logicToolBar")); | logic_tool_bar_->setObjectName(QStringLiteral("logicToolBar")); | ||||
| logic_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); | 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( | add_normally_open_action_ = logic_tool_bar_->addAction( | ||||
| style()->standardIcon(QStyle::SP_ArrowRight), tr("常开")); | style()->standardIcon(QStyle::SP_ArrowRight), tr("常开")); | ||||
| add_normally_closed_action_ = logic_tool_bar_->addAction( | add_normally_closed_action_ = logic_tool_bar_->addAction( | ||||
| @@ -245,6 +251,8 @@ void MainWindow::configureActions() | |||||
| style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("D 比较")); | style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("D 比较")); | ||||
| delete_logic_action_ = logic_tool_bar_->addAction( | delete_logic_action_ = logic_tool_bar_->addAction( | ||||
| style()->standardIcon(QStyle::SP_TrashIcon), tr("删除")); | 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_open_action_->setObjectName(QStringLiteral("addNormallyOpenAction")); | ||||
| add_normally_closed_action_->setObjectName(QStringLiteral("addNormallyClosedAction")); | add_normally_closed_action_->setObjectName(QStringLiteral("addNormallyClosedAction")); | ||||
| add_normal_coil_action_->setObjectName(QStringLiteral("addNormalCoilAction")); | add_normal_coil_action_->setObjectName(QStringLiteral("addNormalCoilAction")); | ||||
| @@ -252,18 +260,22 @@ void MainWindow::configureActions() | |||||
| add_reset_coil_action_->setObjectName(QStringLiteral("addResetCoilAction")); | add_reset_coil_action_->setObjectName(QStringLiteral("addResetCoilAction")); | ||||
| add_compare_action_->setObjectName(QStringLiteral("addCompareAction")); | add_compare_action_->setObjectName(QStringLiteral("addCompareAction")); | ||||
| delete_logic_action_->setObjectName(QStringLiteral("deleteLogicAction")); | 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, | connect(add_normally_open_action_, &QAction::triggered, | ||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| addLogicNode(ContactNodeConfig{ | |||||
| addLogicCondition(ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| ContactMode::NormallyOpen}); | ContactMode::NormallyOpen}); | ||||
| }); | }); | ||||
| @@ -271,7 +283,7 @@ void MainWindow::configureActions() | |||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| addLogicNode(ContactNodeConfig{ | |||||
| addLogicCondition(ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| ContactMode::NormallyClosed}); | ContactMode::NormallyClosed}); | ||||
| }); | }); | ||||
| @@ -279,7 +291,7 @@ void MainWindow::configureActions() | |||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| addLogicNode(CoilNodeConfig{ | |||||
| setLogicOutput(CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| CoilMode::Normal}); | CoilMode::Normal}); | ||||
| }); | }); | ||||
| @@ -287,7 +299,7 @@ void MainWindow::configureActions() | |||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| addLogicNode(CoilNodeConfig{ | |||||
| setLogicOutput(CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| CoilMode::Set}); | CoilMode::Set}); | ||||
| }); | }); | ||||
| @@ -295,7 +307,7 @@ void MainWindow::configureActions() | |||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| addLogicNode(CoilNodeConfig{ | |||||
| setLogicOutput(CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| CoilMode::Reset}); | CoilMode::Reset}); | ||||
| }); | }); | ||||
| @@ -303,7 +315,7 @@ void MainWindow::configureActions() | |||||
| this, | this, | ||||
| [this] | [this] | ||||
| { | { | ||||
| addLogicNode(CompareNodeConfig{ | |||||
| addLogicCondition(CompareNodeConfig{ | |||||
| RegisterAddress{RegisterArea::D, 0}, | RegisterAddress{RegisterArea::D, 0}, | ||||
| ComparisonOperator::Equal, | ComparisonOperator::Equal, | ||||
| 0}); | 0}); | ||||
| @@ -435,13 +447,6 @@ void MainWindow::configureLogicEditor() | |||||
| { | { | ||||
| showLogicNodeProperties(toUtf8(id)); | showLogicNodeProperties(toUtf8(id)); | ||||
| }); | }); | ||||
| connect(logic_editor_widget_, &LogicEditorWidget::nodeChanged, | |||||
| this, | |||||
| [this](const QString &id) | |||||
| { | |||||
| showLogicNodeProperties(toUtf8(id)); | |||||
| refreshProjectUi(); | |||||
| }); | |||||
| connect(logic_editor_widget_, &LogicEditorWidget::graphChanged, | connect(logic_editor_widget_, &LogicEditorWidget::graphChanged, | ||||
| this, &MainWindow::refreshProjectUi); | this, &MainWindow::refreshProjectUi); | ||||
| connect(logic_editor_widget_, &LogicEditorWidget::editorError, | connect(logic_editor_widget_, &LogicEditorWidget::editorError, | ||||
| @@ -802,18 +807,49 @@ void MainWindow::applySelectedControlProperties() | |||||
| statusBar()->showMessage(tr("控件属性已更新"), 3000); | 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) | if (!result.succeeded) | ||||
| { | { | ||||
| showProjectResult(tr("添加逻辑节点"), fromUtf8(result.message), false); | |||||
| showProjectResult(tr("设置输出线圈"), fromUtf8(result.message), false); | |||||
| return; | return; | ||||
| } | } | ||||
| selected_logic_node_id_ = result.id; | selected_logic_node_id_ = result.id; | ||||
| showLogicNodeProperties(result.id); | showLogicNodeProperties(result.id); | ||||
| refreshProjectUi(); | 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() | void MainWindow::deleteSelectedLogicObject() | ||||
| @@ -92,9 +92,13 @@ private: | |||||
| void deleteSelectedControl(); | void deleteSelectedControl(); | ||||
| // 将属性表单内容应用到当前选中控件 | // 将属性表单内容应用到当前选中控件 | ||||
| void applySelectedControlProperties(); | void applySelectedControlProperties(); | ||||
| // 向当前控制逻辑添加指定配置的节点 | |||||
| void addLogicNode(const LogicNodeConfig &config); | |||||
| // 删除当前选中的逻辑节点或连接 | |||||
| // 向当前网络添加串联条件,或按当前插入模式添加并联条件 | |||||
| void addLogicCondition(const LogicNodeConfig &config); | |||||
| // 设置当前网络右侧的输出线圈 | |||||
| void setLogicOutput(const LogicNodeConfig &config); | |||||
| // 新增一个梯形图网络 | |||||
| void addLogicRung(); | |||||
| // 删除当前选中的逻辑节点或网络 | |||||
| void deleteSelectedLogicObject(); | void deleteSelectedLogicObject(); | ||||
| // 将逻辑属性表单内容应用到当前选中节点 | // 将逻辑属性表单内容应用到当前选中节点 | ||||
| void applySelectedLogicNodeProperties(); | void applySelectedLogicNodeProperties(); | ||||
| @@ -150,6 +154,8 @@ private: | |||||
| QAction *add_numeric_display_action_ = nullptr; | QAction *add_numeric_display_action_ = nullptr; | ||||
| QAction *add_numeric_input_action_ = nullptr; | QAction *add_numeric_input_action_ = nullptr; | ||||
| QAction *delete_control_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_open_action_ = nullptr; | ||||
| QAction *add_normally_closed_action_ = nullptr; | QAction *add_normally_closed_action_ = nullptr; | ||||
| QAction *add_normal_coil_action_ = nullptr; | QAction *add_normal_coil_action_ = nullptr; | ||||
| @@ -56,7 +56,7 @@ void testRegisterRepositorySeparatesAreas() | |||||
| Project makeValidProject() | Project makeValidProject() | ||||
| { | { | ||||
| // 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线 | |||||
| // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | |||||
| HmiControl start_button; | HmiControl start_button; | ||||
| start_button.id = "start-button"; | start_button.id = "start-button"; | ||||
| start_button.type = HmiControlType::Button; | start_button.type = HmiControlType::Button; | ||||
| @@ -83,8 +83,15 @@ Project makeValidProject() | |||||
| ControlLogic logic; | ControlLogic logic; | ||||
| logic.id = "start-logic"; | logic.id = "start-logic"; | ||||
| logic.name = "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 project; | ||||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | 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"); | 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; | LogicNode start; | ||||
| start.id = "start"; | start.id = "start"; | ||||
| start.config = ContactNodeConfig{ | start.config = ContactNodeConfig{ | ||||
| @@ -140,29 +153,34 @@ void testLogicGraphBoundaries() | |||||
| ControlLogic logic; | ControlLogic logic; | ||||
| logic.id = "hold-logic"; | logic.id = "hold-logic"; | ||||
| logic.name = "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() | void testModelsValidateBindingsAndIdentifiers() | ||||
| @@ -225,7 +243,7 @@ int main() | |||||
| testRegisterAddressBoundaries(); | testRegisterAddressBoundaries(); | ||||
| testRegisterRepositorySeparatesAreas(); | testRegisterRepositorySeparatesAreas(); | ||||
| testLogicNodeConfigurationBoundaries(); | testLogicNodeConfigurationBoundaries(); | ||||
| testLogicGraphBoundaries(); | |||||
| testLadderLogicBoundaries(); | |||||
| testModelsValidateBindingsAndIdentifiers(); | testModelsValidateBindingsAndIdentifiers(); | ||||
| testRuntimeStateBoundaries(); | testRuntimeStateBoundaries(); | ||||
| } | } | ||||
| @@ -39,33 +39,53 @@ void testEditorOperations() | |||||
| require(logic_result.succeeded, "default logic must be created"); | require(logic_result.succeeded, "default logic must be created"); | ||||
| const std::string logic_id = logic_result.id; | 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, | logic_id, | ||||
| rung_id, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| ContactMode::NormallyClosed}); | |||||
| const LogicEditorResult start_result = service.appendCondition( | |||||
| logic_id, | |||||
| rung_id, | |||||
| ContactNodeConfig{ | ContactNodeConfig{ | ||||
| RegisterAddress{RegisterArea::M, 0}, | 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, | 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{ | 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); | 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( | require(service.updateNodeConfig( | ||||
| logic_id, | logic_id, | ||||
| start_result.id, | start_result.id, | ||||
| @@ -75,12 +95,21 @@ void testEditorOperations() | |||||
| .succeeded, | .succeeded, | ||||
| "contact properties must be editable"); | "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, | 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 | } // namespace | ||||
| @@ -82,6 +82,8 @@ void testModeActionsControlEditingAvailability() | |||||
| window, "addNormallyOpenAction"); | window, "addNormallyOpenAction"); | ||||
| QAction *add_normal_coil_action = requiredChild<QAction>( | QAction *add_normal_coil_action = requiredChild<QAction>( | ||||
| window, "addNormalCoilAction"); | window, "addNormalCoilAction"); | ||||
| QAction *parallel_insert_action = requiredChild<QAction>( | |||||
| window, "parallelInsertAction"); | |||||
| QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock"); | QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock"); | ||||
| QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | ||||
| QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | ||||
| @@ -111,12 +113,19 @@ void testModeActionsControlEditingAvailability() | |||||
| require(editor_service.findPage(page_id)->controls.empty(), | require(editor_service.findPage(page_id)->controls.empty(), | ||||
| "deleting a selected control must update the HMI page model"); | "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_normally_open_action->trigger(); | ||||
| add_normal_coil_action->trigger(); | add_normal_coil_action->trigger(); | ||||
| const ControlLogic *logic = logic_editor_service.findLogic( | const ControlLogic *logic = logic_editor_service.findLogic( | ||||
| logic_editor_service.firstLogicId()); | 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(); | offline_action->trigger(); | ||||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | require(mode_service.mode() == ApplicationMode::OfflineRunning, | ||||
| @@ -22,7 +22,7 @@ void require(bool condition, const std::string &message) | |||||
| Project makeExampleProject() | Project makeExampleProject() | ||||
| { | { | ||||
| // 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本 | |||||
| // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本 | |||||
| HmiControl start_button; | HmiControl start_button; | ||||
| start_button.id = "start-button"; | start_button.id = "start-button"; | ||||
| start_button.type = HmiControlType::Button; | start_button.type = HmiControlType::Button; | ||||
| @@ -68,7 +68,6 @@ Project makeExampleProject() | |||||
| contact.config = ContactNodeConfig{ | contact.config = ContactNodeConfig{ | ||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| ContactMode::NormallyOpen}; | ContactMode::NormallyOpen}; | ||||
| contact.position = {80, 100}; | |||||
| LogicNode compare; | LogicNode compare; | ||||
| compare.id = "temperature-check"; | compare.id = "temperature-check"; | ||||
| @@ -76,22 +75,30 @@ Project makeExampleProject() | |||||
| RegisterAddress{RegisterArea::D, 2}, | RegisterAddress{RegisterArea::D, 2}, | ||||
| ComparisonOperator::GreaterThanOrEqual, | ComparisonOperator::GreaterThanOrEqual, | ||||
| static_cast<std::int16_t>(100)}; | 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; | LogicNode coil; | ||||
| coil.id = "run-coil"; | coil.id = "run-coil"; | ||||
| coil.config = CoilNodeConfig{ | coil.config = CoilNodeConfig{ | ||||
| RegisterAddress{RegisterArea::M, 1}, | RegisterAddress{RegisterArea::M, 1}, | ||||
| CoilMode::Set}; | CoilMode::Set}; | ||||
| coil.position = {560, 100}; | |||||
| ControlLogic logic; | ControlLogic logic; | ||||
| logic.id = "start-logic"; | logic.id = "start-logic"; | ||||
| logic.name = "Start logic"; | logic.name = "Start logic"; | ||||
| logic.enabled = false; | 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 project; | ||||
| project.metadata = {"example-project", "Example project", "1.0"}; | project.metadata = {"example-project", "Example project", "1.0"}; | ||||
| @@ -154,6 +161,15 @@ void testExampleProjectRoundTrip() | |||||
| const QString second_path = directory.filePath("example-copy.json"); | const QString second_path = directory.filePath("example-copy.json"); | ||||
| require(service.saveAs(first_path.toStdString()).succeeded, | require(service.saveAs(first_path.toStdString()).succeeded, | ||||
| "example project save must succeed"); | "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, | require(service.load(first_path.toStdString()).succeeded, | ||||
| "example project load must succeed"); | "example project load must succeed"); | ||||
| @@ -177,13 +193,16 @@ void testExampleProjectRoundTrip() | |||||
| "control logic count must survive round trip"); | "control logic count must survive round trip"); | ||||
| require(!project.controlLogics.front().enabled, | require(!project.controlLogics.front().enabled, | ||||
| "control logic enabled state must survive round trip"); | "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>( | 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, | require(compare.address.index() == 2 && compare.value == 100, | ||||
| "comparison configuration must survive round trip"); | "comparison configuration must survive round trip"); | ||||
| @@ -86,12 +86,11 @@ | |||||
| - 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。 | - 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。 | ||||
| - 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。 | - 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。 | ||||
| - `HmiRuntimeService` 仅通过 `RegisterRepository` 访问寄存器,不依赖虚拟寄存器、串口或 Modbus。 | - `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: | - Validation run and results: | ||||
| - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | ||||
| - 已启动 `integrated_platform.exe` 并正常退出。 | - 已启动 `integrated_platform.exe` 并正常退出。 | ||||
| @@ -115,6 +114,7 @@ | |||||
| - 控制逻辑编辑器完成后,领域、逻辑编辑服务、工程管理和主窗口测试全部通过。 | - 控制逻辑编辑器完成后,领域、逻辑编辑服务、工程管理和主窗口测试全部通过。 | ||||
| - Debug 应用构建成功。 | - Debug 应用构建成功。 | ||||
| - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 | - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 | ||||
| - 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。 | |||||
| - Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。 | - Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。 | ||||
| - Known risks / blockers: 无。 | - Known risks / blockers: 无。 | ||||
| - Suggested next command: 为控制逻辑建立固定周期的软件执行器并按拓扑顺序求值。 | |||||
| - Suggested next command: 为控制逻辑建立固定周期的软件执行器,按网络顺序执行并计算级间 AND 与级内 OR。 | |||||
| @@ -60,7 +60,7 @@ main.cpp -> UI + Services + Infrastructure | |||||
| | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | ||||
| | `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 | | | `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 | | ||||
| | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定,并校验控件位于页面边界内。 | | | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定,并校验控件位于页面边界内。 | | ||||
| | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | |||||
| | `control_logic_model.*` | 定义网络、串联级、并联条件和输出线圈,并校验节点类型及地址区域。 | | |||||
| | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | ||||
| | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | ||||
| @@ -84,15 +84,15 @@ main.cpp -> UI + Services + Infrastructure | |||||
| 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | ||||
| 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | ||||
| 控制逻辑使用有向无环图表达。没有前驱的条件节点从隐式左母线取得真值,条件节点输出为 | |||||
| 前驱输入与自身条件的逻辑与;多个前驱在汇合处执行逻辑或,一个输出连接多个后继表示并行 | |||||
| 分支。线圈只能作为终点。状态保持通过触点和线圈绑定同一个 M 地址表达,不建立从线圈返回 | |||||
| 触点的环形连接。上述语义是后续软件逻辑执行器的确定性求值契约。 | |||||
| 控制逻辑按标准梯形图的受限结构表达:一个逻辑包含多个网络,一个网络包含按顺序串联的多个 | |||||
| 条件级,每个条件级包含一个或多个并联条件,输出线圈固定在网络最右侧。条件级之间执行逻辑与, | |||||
| 同级并联条件执行逻辑或。状态保持通过启动触点和运行反馈触点并联,并与停止触点串联后驱动运行 | |||||
| 线圈表达。该结构是后续软件逻辑执行器的确定性求值契约,不需要通用图遍历或连接表。 | |||||
| `services/LogicEditorService` 负责逻辑节点的添加、移动、属性更新、连接、断开和删除。编辑服务 | |||||
| 只要求每次操作后的结构合法,允许用户暂时保留尚未连接完成的中间状态;工程保存仍调用完整 | |||||
| 领域校验,拒绝无输入线圈和不能到达线圈的条件节点。`ui/LogicEditorWidget` 只投影节点标识和 | |||||
| 连接端点,通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。节点画布位置随工程保存。 | |||||
| `services/LogicEditorService` 负责新增和删除网络、追加串联条件、增加并联条件、设置输出线圈、 | |||||
| 修改属性和删除节点。空网络可作为编辑占位;非空网络保存时必须同时具备条件和输出线圈。 | |||||
| `ui/LogicEditorWidget` 自动排列左右母线、串联级、并联支路和右侧线圈,不允许自由拖动或手动画线, | |||||
| 也不保存像素坐标。编辑器通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。 | |||||
| ## 运行模式边界 | ## 运行模式边界 | ||||
| @@ -53,11 +53,12 @@ | |||||
| ## 7. 实现控制逻辑编辑器 | ## 7. 实现控制逻辑编辑器 | ||||
| - 建立图形逻辑模型,支持基本连接、删除和属性配置。 | |||||
| - 建立梯形图逻辑模型,支持网络、串联条件、并联支路、输出线圈、删除和属性配置。 | |||||
| - 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。 | - 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。 | ||||
| - 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。 | - 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。 | ||||
| - 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。 | - 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。 | ||||
| - 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 | - 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 | ||||
| - 画布按梯形图结构自动布局,不允许节点自由拖动,也不要求用户手动画线。 | |||||
| 完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 | 完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 | ||||