| @@ -25,10 +25,12 @@ SOURCES += \ | |||
| src/domain/runtime_state.cpp \ | |||
| src/services/project_service.cpp \ | |||
| src/services/hmi_editor_service.cpp \ | |||
| src/services/logic_editor_service.cpp \ | |||
| src/services/hmi_runtime_service.cpp \ | |||
| src/services/runtime_mode_service.cpp \ | |||
| src/infrastructure/json_project_storage.cpp \ | |||
| src/ui/hmi_editor_widget.cpp | |||
| src/ui/hmi_editor_widget.cpp \ | |||
| src/ui/logic_editor_widget.cpp | |||
| HEADERS += \ | |||
| src/ui/main_window.h \ | |||
| @@ -41,10 +43,12 @@ HEADERS += \ | |||
| src/domain/project_storage.h \ | |||
| src/services/project_service.h \ | |||
| src/services/hmi_editor_service.h \ | |||
| src/services/logic_editor_service.h \ | |||
| src/services/hmi_runtime_service.h \ | |||
| src/services/runtime_mode_service.h \ | |||
| src/infrastructure/json_project_storage.h \ | |||
| src/ui/hmi_editor_widget.h | |||
| src/ui/hmi_editor_widget.h \ | |||
| src/ui/logic_editor_widget.h | |||
| FORMS += \ | |||
| src/ui/main_window.ui | |||
| @@ -1,6 +1,8 @@ | |||
| #include "control_logic_model.h" | |||
| #include <algorithm> | |||
| #include <unordered_map> | |||
| #include <unordered_set> | |||
| namespace { | |||
| @@ -86,6 +88,12 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| } // namespace | |||
| bool LogicPoint::isValid() const | |||
| { | |||
| return x >= kMinimumCoordinate && x <= kMaximumCoordinate | |||
| && y >= kMinimumCoordinate && y <= kMaximumCoordinate; | |||
| } | |||
| bool LogicNode::validate(std::string *error) const | |||
| { | |||
| if (id.empty()) | |||
| @@ -93,6 +101,11 @@ bool LogicNode::validate(std::string *error) const | |||
| setError(error, "logic node id must not be empty"); | |||
| return false; | |||
| } | |||
| if (!position.isValid()) | |||
| { | |||
| setError(error, "logic node position is outside the supported range"); | |||
| return false; | |||
| } | |||
| // 根据 variant 中实际保存的配置类型调用对应校验函数 | |||
| return std::visit( | |||
| @@ -115,6 +128,81 @@ bool LogicConnection::validate(std::string *error) const | |||
| } | |||
| bool ControlLogic::validate(std::string *error) const | |||
| { | |||
| if (!validateStructure(error)) | |||
| { | |||
| return false; | |||
| } | |||
| if (id.empty() || name.empty()) | |||
| { | |||
| setError(error, "control logic id and name must not be empty"); | |||
| 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) | |||
| { | |||
| if (std::holds_alternative<CoilNodeConfig>(node.config) | |||
| && incoming_count.at(node.id) == 0U) | |||
| { | |||
| setError(error, "coil node must have at least one input connection"); | |||
| return false; | |||
| } | |||
| } | |||
| // 从所有线圈沿反向连接遍历,一次找出能够产生动作的全部条件节点 | |||
| std::unordered_map<std::string, std::vector<std::string>> incoming; | |||
| std::vector<std::string> pending; | |||
| for (const LogicNode &node : nodes) | |||
| { | |||
| incoming.emplace(node.id, std::vector<std::string>{}); | |||
| if (std::holds_alternative<CoilNodeConfig>(node.config)) | |||
| { | |||
| pending.push_back(node.id); | |||
| } | |||
| } | |||
| for (const LogicConnection &connection : connections) | |||
| { | |||
| incoming.at(connection.toNodeId).push_back(connection.fromNodeId); | |||
| } | |||
| std::unordered_set<std::string> reaches_coil; | |||
| while (!pending.empty()) | |||
| { | |||
| 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); | |||
| } | |||
| } | |||
| if (reaches_coil.size() != nodes.size()) | |||
| { | |||
| setError(error, "condition node must reach a coil node"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool ControlLogic::validateStructure(std::string *error) const | |||
| { | |||
| if (id.empty() || name.empty()) | |||
| { | |||
| @@ -155,5 +243,79 @@ bool ControlLogic::validate(std::string *error) const | |||
| return false; | |||
| } | |||
| } | |||
| // 同一对节点只能存在一条有向连接,避免执行语义和删除行为产生歧义 | |||
| for (auto current = connections.cbegin(); current != connections.cend(); ++current) | |||
| { | |||
| 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; | |||
| } | |||
| } | |||
| 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) | |||
| { | |||
| outgoing.emplace(node.id, std::vector<std::string>{}); | |||
| incoming_count.emplace(node.id, 0U); | |||
| } | |||
| for (const LogicConnection &connection : connections) | |||
| { | |||
| 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)) | |||
| { | |||
| setError(error, "coil node cannot have outgoing connections"); | |||
| return false; | |||
| } | |||
| outgoing.at(connection.fromNodeId).push_back(connection.toNodeId); | |||
| ++incoming_count.at(connection.toNodeId); | |||
| } | |||
| // 使用迭代拓扑排序校验环路,不改变节点的持久化顺序 | |||
| std::vector<std::string> pending; | |||
| for (const auto &entry : incoming_count) | |||
| { | |||
| if (entry.second == 0U) | |||
| { | |||
| pending.push_back(entry.first); | |||
| } | |||
| } | |||
| std::size_t visited_count = 0U; | |||
| while (!pending.empty()) | |||
| { | |||
| const std::string current = pending.back(); | |||
| pending.pop_back(); | |||
| ++visited_count; | |||
| for (const std::string &next : outgoing.at(current)) | |||
| { | |||
| std::size_t &count = incoming_count.at(next); | |||
| --count; | |||
| if (count == 0U) | |||
| { | |||
| pending.push_back(next); | |||
| } | |||
| } | |||
| } | |||
| if (visited_count != nodes.size()) | |||
| { | |||
| setError(error, "logic connections must not contain a cycle"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -7,6 +7,18 @@ | |||
| #include <variant> | |||
| #include <vector> | |||
| // 逻辑节点在编辑画布中的持久化坐标 | |||
| struct LogicPoint | |||
| { | |||
| static constexpr int kMinimumCoordinate = 0; | |||
| static constexpr int kMaximumCoordinate = 100000; | |||
| int x = 0; | |||
| int y = 0; | |||
| bool isValid() const; | |||
| }; | |||
| // 触点工作方式 | |||
| enum class ContactMode | |||
| { | |||
| @@ -72,6 +84,8 @@ struct LogicNode | |||
| std::string id; | |||
| // 节点自身的业务配置 | |||
| LogicNodeConfig config; | |||
| // 节点在逻辑编辑画布中的位置 | |||
| LogicPoint position; | |||
| // 校验节点配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| @@ -105,4 +119,6 @@ struct ControlLogic | |||
| // 校验控制逻辑并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| // 校验编辑过程所需的结构规则,允许暂时没有完整输出 | |||
| bool validateStructure(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -211,6 +211,38 @@ QJsonObject serializeAddress(const RegisterAddress &address) | |||
| return object; | |||
| } | |||
| QJsonObject serializeLogicPoint(const LogicPoint &position) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("x"), position.x); | |||
| object.insert(QStringLiteral("y"), position.y); | |||
| return object; | |||
| } | |||
| bool parseLogicPoint( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| LogicPoint *position, | |||
| ParseState *state) | |||
| { | |||
| return readInt( | |||
| object, | |||
| "x", | |||
| context, | |||
| LogicPoint::kMinimumCoordinate, | |||
| LogicPoint::kMaximumCoordinate, | |||
| &position->x, | |||
| state) | |||
| && readInt( | |||
| object, | |||
| "y", | |||
| context, | |||
| LogicPoint::kMinimumCoordinate, | |||
| LogicPoint::kMaximumCoordinate, | |||
| &position->y, | |||
| state); | |||
| } | |||
| // 解析寄存器地址并校验区域名称及项目允许的索引范围 | |||
| bool parseAddress( | |||
| const QJsonObject &object, | |||
| @@ -719,6 +751,7 @@ QJsonObject serializeLogicNode(const LogicNode &node) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(node.id)); | |||
| object.insert(QStringLiteral("position"), serializeLogicPoint(node.position)); | |||
| // std::visit 将不同节点配置统一转换为 config JSON 对象 | |||
| object.insert( | |||
| QStringLiteral("config"), | |||
| @@ -814,9 +847,19 @@ bool parseLogicNode( | |||
| ParseState *state) | |||
| { | |||
| QJsonObject config; | |||
| return readString(object, "id", context, &node->id, state) | |||
| && readObject(object, "config", context, &config, state) | |||
| && parseNodeConfig(config, context + ".config", &node->config, state); | |||
| QJsonObject position; | |||
| if (!readString(object, "id", context, &node->id, state) | |||
| || !readObject(object, "config", context, &config, state) | |||
| || !readObject(object, "position", context, &position, state) | |||
| || !parseNodeConfig(config, context + ".config", &node->config, state)) | |||
| { | |||
| return false; | |||
| } | |||
| return parseLogicPoint( | |||
| position, | |||
| context + ".position", | |||
| &node->position, | |||
| state); | |||
| } | |||
| // 将逻辑节点之间的有向连接序列化为 JSON 对象 | |||
| @@ -11,6 +11,7 @@ | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "ui/main_window.h" | |||
| @@ -25,6 +26,7 @@ int main(int argc, char *argv[]) | |||
| JsonProjectStorage project_storage; | |||
| ProjectService project_service(project_storage); | |||
| HmiEditorService hmi_editor_service(project_service); | |||
| LogicEditorService logic_editor_service(project_service); | |||
| // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | |||
| VirtualRegisterRepository virtual_register_repository; | |||
| HmiRuntimeService hmi_runtime_service(virtual_register_repository); | |||
| @@ -33,6 +35,7 @@ int main(int argc, char *argv[]) | |||
| runtime_mode_service, | |||
| project_service, | |||
| hmi_editor_service, | |||
| logic_editor_service, | |||
| hmi_runtime_service); | |||
| main_window.show(); | |||
| @@ -0,0 +1,395 @@ | |||
| #include "logic_editor_service.h" | |||
| #include "project_service.h" | |||
| #include <algorithm> | |||
| #include <sstream> | |||
| #include <type_traits> | |||
| #include <utility> | |||
| namespace { | |||
| bool validateCandidateNode( | |||
| const std::string &id, | |||
| const LogicNodeConfig &config, | |||
| const LogicPoint &position, | |||
| std::string *error) | |||
| { | |||
| LogicNode candidate; | |||
| candidate.id = id; | |||
| candidate.config = config; | |||
| candidate.position = position; | |||
| return candidate.validate(error); | |||
| } | |||
| } // namespace | |||
| LogicEditorService::LogicEditorService(ProjectService &project_service) | |||
| : project_service_(project_service) | |||
| { | |||
| } | |||
| const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| const auto logic = std::find_if( | |||
| project.controlLogics.cbegin(), | |||
| project.controlLogics.cend(), | |||
| [&logic_id](const ControlLogic &candidate) | |||
| { | |||
| return candidate.id == logic_id; | |||
| }); | |||
| return logic == project.controlLogics.cend() ? nullptr : &*logic; | |||
| } | |||
| const LogicNode *LogicEditorService::findNode( | |||
| const std::string &logic_id, const std::string &node_id) const | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return nullptr; | |||
| } | |||
| const auto node = std::find_if( | |||
| logic->nodes.cbegin(), | |||
| logic->nodes.cend(), | |||
| [&node_id](const LogicNode &candidate) | |||
| { | |||
| return candidate.id == node_id; | |||
| }); | |||
| return node == logic->nodes.cend() ? nullptr : &*node; | |||
| } | |||
| std::string LogicEditorService::firstLogicId() const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| return project.controlLogics.empty() ? std::string{} : project.controlLogics.front().id; | |||
| } | |||
| LogicEditorResult LogicEditorService::ensureDefaultLogic() | |||
| { | |||
| if (!project_service_.project().controlLogics.empty()) | |||
| { | |||
| return {true, LogicEditorError::None, {}, firstLogicId()}; | |||
| } | |||
| ControlLogic logic; | |||
| logic.id = "logic-1"; | |||
| logic.name = "控制逻辑 1"; | |||
| Project &project = project_service_.editProject(); | |||
| project.controlLogics.push_back(std::move(logic)); | |||
| return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addNode( | |||
| const std::string &logic_id, | |||
| const LogicNodeConfig &config, | |||
| const LogicPoint &position) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| const std::string id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| std::string error; | |||
| if (!validateCandidateNode(id, config, position, &error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| 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; | |||
| }); | |||
| LogicNode node; | |||
| node.id = id; | |||
| node.config = config; | |||
| node.position = position; | |||
| target->nodes.push_back(std::move(node)); | |||
| return {true, LogicEditorError::None, {}, id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::moveNode( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicPoint &position) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LogicNode *node = findNode(logic_id, node_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (node == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| } | |||
| if (!position.isValid()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidNode, | |||
| "logic node position is outside the supported range"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) | |||
| { | |||
| return candidate.id == logic_id; | |||
| }); | |||
| auto editable = std::find_if( | |||
| target->nodes.begin(), | |||
| target->nodes.end(), | |||
| [&node_id](const LogicNode &candidate) | |||
| { | |||
| return candidate.id == node_id; | |||
| }); | |||
| editable->position = position; | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicNodeConfig &config) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LogicNode *node = findNode(logic_id, node_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (node == nullptr) | |||
| { | |||
| 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"); | |||
| } | |||
| std::string error; | |||
| if (!validateCandidateNode(node_id, config, node->position, &error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &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; | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::connectNodes( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (findNode(logic_id, from_node_id) == nullptr | |||
| || findNode(logic_id, to_node_id) == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic connection node was not found"); | |||
| } | |||
| if (hasConnection(*logic, from_node_id, to_node_id)) | |||
| { | |||
| return failure( | |||
| LogicEditorError::DuplicateConnection, | |||
| "logic connection already exists"); | |||
| } | |||
| ControlLogic candidate = *logic; | |||
| candidate.connections.push_back({from_node_id, to_node_id}); | |||
| std::string error; | |||
| if (!candidate.validateStructure(&error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidConnection, error); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &item) | |||
| { | |||
| return item.id == logic_id; | |||
| }); | |||
| target->connections.push_back({from_node_id, to_node_id}); | |||
| return {true, LogicEditorError::None, {}, to_node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::disconnectNodes( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| const auto connection = std::find_if( | |||
| logic->connections.cbegin(), | |||
| logic->connections.cend(), | |||
| [&from_node_id, &to_node_id](const LogicConnection &candidate) | |||
| { | |||
| return candidate.fromNodeId == from_node_id | |||
| && candidate.toNodeId == to_node_id; | |||
| }); | |||
| if (connection == logic->connections.cend()) | |||
| { | |||
| return failure(LogicEditorError::InvalidConnection, "logic connection was not found"); | |||
| } | |||
| 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.erase( | |||
| std::remove_if( | |||
| target->connections.begin(), | |||
| target->connections.end(), | |||
| [&from_node_id, &to_node_id](const LogicConnection &candidate) | |||
| { | |||
| return candidate.fromNodeId == from_node_id | |||
| && candidate.toNodeId == to_node_id; | |||
| }), | |||
| target->connections.end()); | |||
| return {true, LogicEditorError::None, {}, to_node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::removeNode( | |||
| const std::string &logic_id, const std::string &node_id) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "control logic was not found"); | |||
| } | |||
| if (findNode(logic_id, node_id) == nullptr) | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "logic node was not found"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.controlLogics.begin(), | |||
| project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &item) | |||
| { | |||
| return item.id == logic_id; | |||
| }); | |||
| target->nodes.erase( | |||
| std::remove_if( | |||
| target->nodes.begin(), | |||
| target->nodes.end(), | |||
| [&node_id](const LogicNode &candidate) | |||
| { | |||
| 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) | |||
| { | |||
| return candidate.fromNodeId == node_id || candidate.toNodeId == node_id; | |||
| }), | |||
| target->connections.end()); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) | |||
| { | |||
| return std::visit( | |||
| [](const auto &value) -> std::string | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| { | |||
| return "contact"; | |||
| } | |||
| if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| { | |||
| return "coil"; | |||
| } | |||
| return "compare"; | |||
| }, | |||
| config); | |||
| } | |||
| std::string LogicEditorService::makeUniqueNodeId( | |||
| const ControlLogic &logic, const std::string &prefix) | |||
| { | |||
| 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) | |||
| { | |||
| return node.id == candidate; | |||
| }); | |||
| if (match == logic.nodes.cend()) | |||
| { | |||
| return candidate; | |||
| } | |||
| } | |||
| } | |||
| bool LogicEditorService::hasConnection( | |||
| const ControlLogic &logic, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id) | |||
| { | |||
| 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(); | |||
| } | |||
| LogicEditorResult LogicEditorService::failure( | |||
| LogicEditorError error, const std::string &message) | |||
| { | |||
| return {false, error, message, {}}; | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include <string> | |||
| class ProjectService; | |||
| enum class LogicEditorError | |||
| { | |||
| None, | |||
| LogicNotFound, | |||
| NodeNotFound, | |||
| DuplicateConnection, | |||
| InvalidNode, | |||
| InvalidConnection, | |||
| UnsupportedNodeChange | |||
| }; | |||
| struct LogicEditorResult | |||
| { | |||
| bool succeeded = false; | |||
| LogicEditorError error = LogicEditorError::None; | |||
| std::string message; | |||
| std::string id; | |||
| }; | |||
| class LogicEditorService | |||
| { | |||
| public: | |||
| explicit LogicEditorService(ProjectService &project_service); | |||
| const ControlLogic *findLogic(const std::string &logic_id) const; | |||
| const LogicNode *findNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| std::string firstLogicId() const; | |||
| LogicEditorResult ensureDefaultLogic(); | |||
| LogicEditorResult addNode( | |||
| const std::string &logic_id, | |||
| const LogicNodeConfig &config, | |||
| const LogicPoint &position); | |||
| LogicEditorResult moveNode( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicPoint &position); | |||
| LogicEditorResult updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult connectNodes( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id); | |||
| LogicEditorResult disconnectNodes( | |||
| const std::string &logic_id, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id); | |||
| LogicEditorResult removeNode( | |||
| const std::string &logic_id, const std::string &node_id); | |||
| private: | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| static std::string makeUniqueNodeId( | |||
| const ControlLogic &logic, const std::string &prefix); | |||
| static bool hasConnection( | |||
| const ControlLogic &logic, | |||
| const std::string &from_node_id, | |||
| const std::string &to_node_id); | |||
| static LogicEditorResult failure( | |||
| LogicEditorError error, const std::string &message); | |||
| ProjectService &project_service_; | |||
| }; | |||
| @@ -0,0 +1,624 @@ | |||
| #include "logic_editor_widget.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include <QGraphicsPathItem> | |||
| #include <QGraphicsScene> | |||
| #include <QGraphicsSceneMouseEvent> | |||
| #include <QMouseEvent> | |||
| #include <QPainter> | |||
| #include <QResizeEvent> | |||
| #include <QStyleOptionGraphicsItem> | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <functional> | |||
| #include <map> | |||
| #include <type_traits> | |||
| #include <utility> | |||
| namespace { | |||
| constexpr qreal kNodeWidth = 168.0; | |||
| constexpr qreal kNodeHeight = 72.0; | |||
| constexpr qreal kPortRadius = 6.0; | |||
| QString nodeTitle(const LogicNodeConfig &config) | |||
| { | |||
| return std::visit( | |||
| [](const auto &value) -> QString | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| { | |||
| return 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); | |||
| } | |||
| QString nodeDetail(const LogicNodeConfig &config) | |||
| { | |||
| return std::visit( | |||
| [](const auto &value) -> QString | |||
| { | |||
| using Config = std::decay_t<decltype(value)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| { | |||
| return QStringLiteral("M%1").arg(value.address.index()); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CoilNodeConfig>) | |||
| { | |||
| return QStringLiteral("M%1").arg(value.address.index()); | |||
| } | |||
| else | |||
| { | |||
| return QStringLiteral("D%1 = %2") | |||
| .arg(value.address.index()) | |||
| .arg(value.value); | |||
| } | |||
| }, | |||
| config); | |||
| } | |||
| bool isCoil(const LogicNodeConfig &config) | |||
| { | |||
| 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; | |||
| } | |||
| } // namespace | |||
| class LogicEditorWidget::NodeItem final : public QGraphicsItem | |||
| { | |||
| public: | |||
| NodeItem( | |||
| const LogicNode &node, | |||
| std::function<void(const std::string &, const QPointF &)> moved, | |||
| std::function<void(const std::string &)> position_changed) | |||
| : node_id_(node.id), | |||
| config_(node.config), | |||
| moved_(std::move(moved)), | |||
| position_changed_(std::move(position_changed)) | |||
| { | |||
| setPos(node.position.x, node.position.y); | |||
| setFlag(ItemIsSelectable, true); | |||
| setFlag(ItemSendsGeometryChanges, true); | |||
| setAcceptedMouseButtons(Qt::LeftButton); | |||
| } | |||
| QRectF boundingRect() const override | |||
| { | |||
| return {0, 0, kNodeWidth, kNodeHeight}; | |||
| } | |||
| void paint( | |||
| QPainter *painter, | |||
| const QStyleOptionGraphicsItem *option, | |||
| QWidget *) override | |||
| { | |||
| const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); | |||
| painter->setRenderHint(QPainter::Antialiasing, true); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1)); | |||
| painter->setBrush(isCoil(config_) | |||
| ? QColor(QStringLiteral("#fff2d8")) | |||
| : QColor(QStringLiteral("#e8f1f6"))); | |||
| painter->drawRoundedRect(rect, 5, 5); | |||
| painter->setPen(QColor(QStringLiteral("#18252e"))); | |||
| painter->drawText( | |||
| rect.adjusted(14, 8, -14, -34), | |||
| Qt::AlignLeft | Qt::AlignVCenter, | |||
| nodeTitle(config_)); | |||
| painter->setPen(QColor(QStringLiteral("#586873"))); | |||
| painter->drawText( | |||
| rect.adjusted(14, 35, -14, -8), | |||
| Qt::AlignLeft | Qt::AlignVCenter, | |||
| nodeDetail(config_)); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1)); | |||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||
| painter->drawEllipse(inputPortRect()); | |||
| if (!isCoil(config_)) | |||
| { | |||
| painter->drawEllipse(outputPortRect()); | |||
| } | |||
| if ((option->state & QStyle::State_Selected) != 0) | |||
| { | |||
| painter->setBrush(Qt::NoBrush); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); | |||
| painter->drawRoundedRect(boundingRect().adjusted(0, 0, -1, -1), 5, 5); | |||
| } | |||
| } | |||
| 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_) | |||
| { | |||
| position_changed_(node_id_); | |||
| } | |||
| return QGraphicsItem::itemChange(change, value); | |||
| } | |||
| void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| QGraphicsItem::mouseReleaseEvent(event); | |||
| if (flags().testFlag(ItemIsMovable) && moved_) | |||
| { | |||
| moved_(node_id_, pos()); | |||
| } | |||
| } | |||
| private: | |||
| std::string node_id_; | |||
| LogicNodeConfig config_; | |||
| std::function<void(const std::string &, const QPointF &)> moved_; | |||
| std::function<void(const std::string &)> position_changed_; | |||
| }; | |||
| class LogicEditorWidget::ConnectionItem final : public QGraphicsPathItem | |||
| { | |||
| 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) | |||
| { | |||
| setFlag(ItemIsSelectable, true); | |||
| setZValue(-1.0); | |||
| setPen(QPen(QColor(QStringLiteral("#5d6f7b")), 2)); | |||
| } | |||
| const std::string &fromNodeId() const | |||
| { | |||
| return from_node_id_; | |||
| } | |||
| const std::string &toNodeId() const | |||
| { | |||
| return to_node_id_; | |||
| } | |||
| void updatePath(const QPointF &from, const QPointF &to) | |||
| { | |||
| setPath(makeConnectionPath(from, to)); | |||
| } | |||
| private: | |||
| std::string from_node_id_; | |||
| std::string to_node_id_; | |||
| }; | |||
| LogicEditorWidget::LogicEditorWidget( | |||
| LogicEditorService &editor_service, | |||
| QWidget *parent) | |||
| : QGraphicsView(parent), editor_service_(editor_service) | |||
| { | |||
| scene_ = new QGraphicsScene(this); | |||
| scene_->setSceneRect(0, 0, 2400, 1400); | |||
| setScene(scene_); | |||
| setRenderHint(QPainter::Antialiasing, true); | |||
| setBackgroundBrush(QColor(QStringLiteral("#ffffff"))); | |||
| setDragMode(QGraphicsView::RubberBandDrag); | |||
| setTransformationAnchor(QGraphicsView::AnchorUnderMouse); | |||
| connect(scene_, &QGraphicsScene::selectionChanged, | |||
| this, &LogicEditorWidget::handleSelectionChanged); | |||
| } | |||
| void LogicEditorWidget::setLogicId(const std::string &logic_id) | |||
| { | |||
| if (logic_id_ == logic_id) | |||
| { | |||
| return; | |||
| } | |||
| logic_id_ = logic_id; | |||
| reloadLogic(); | |||
| } | |||
| void LogicEditorWidget::setEditingEnabled(bool enabled) | |||
| { | |||
| editing_enabled_ = enabled; | |||
| setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag); | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| node->setInteractionEnabled(enabled); | |||
| } | |||
| } | |||
| if (!enabled) | |||
| { | |||
| clearPreview(); | |||
| } | |||
| } | |||
| void LogicEditorWidget::reloadLogic() | |||
| { | |||
| clearPreview(); | |||
| scene_->clear(); | |||
| if (logic_id_.empty()) | |||
| { | |||
| return; | |||
| } | |||
| const ControlLogic *logic = editor_service_.findLogic(logic_id_); | |||
| if (logic == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| std::map<std::string, NodeItem *> node_items; | |||
| for (const LogicConnection &connection : logic->connections) | |||
| { | |||
| scene_->addItem(new ConnectionItem(connection.fromNodeId, connection.toNodeId)); | |||
| } | |||
| for (const LogicNode &node : logic->nodes) | |||
| { | |||
| auto *item = new NodeItem( | |||
| node, | |||
| [this](const std::string &node_id, const QPointF &position) | |||
| { | |||
| 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 &) | |||
| { | |||
| updateConnections(); | |||
| }); | |||
| item->setInteractionEnabled(editing_enabled_); | |||
| scene_->addItem(item); | |||
| node_items.emplace(node.id, item); | |||
| } | |||
| updateConnections(); | |||
| } | |||
| void LogicEditorWidget::selectNode(const std::string &node_id) | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| node->setSelected(node->nodeId() == node_id); | |||
| if (node->nodeId() == node_id) | |||
| { | |||
| ensureVisible(node); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| std::string LogicEditorWidget::selectedNodeId() const | |||
| { | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| const NodeItem *node = dynamic_cast<const NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| return node->nodeId(); | |||
| } | |||
| } | |||
| 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() | |||
| { | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| const LogicEditorResult result = editor_service_.removeNode( | |||
| logic_id_, node->nodeId()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(result); | |||
| return result; | |||
| } | |||
| reloadLogic(); | |||
| emit nodeSelected({}); | |||
| emit graphChanged(); | |||
| return result; | |||
| } | |||
| ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item); | |||
| if (connection != 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 {false, LogicEditorError::NodeNotFound, "no logic object is selected", {}}; | |||
| } | |||
| void LogicEditorWidget::mousePressEvent(QMouseEvent *event) | |||
| { | |||
| if (editing_enabled_ && event->button() == Qt::LeftButton) | |||
| { | |||
| NodeItem *output = nodeAtPort(mapToScene(event->pos()), true); | |||
| if (output != 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; | |||
| } | |||
| } | |||
| QGraphicsView::mousePressEvent(event); | |||
| } | |||
| void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) | |||
| { | |||
| if (!connecting_from_node_id_.empty()) | |||
| { | |||
| updatePreview(mapToScene(event->pos())); | |||
| event->accept(); | |||
| return; | |||
| } | |||
| QGraphicsView::mouseMoveEvent(event); | |||
| } | |||
| void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) | |||
| { | |||
| if (!connecting_from_node_id_.empty()) | |||
| { | |||
| 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; | |||
| } | |||
| 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(); | |||
| } | |||
| void LogicEditorWidget::updateConnections() | |||
| { | |||
| std::map<std::string, NodeItem *> node_items; | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr) | |||
| { | |||
| node_items.emplace(node->nodeId(), node); | |||
| } | |||
| } | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| 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()); | |||
| } | |||
| } | |||
| } | |||
| void LogicEditorWidget::updatePreview(const QPointF &scene_position) | |||
| { | |||
| if (preview_item_ == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node != nullptr && node->nodeId() == connecting_from_node_id_) | |||
| { | |||
| preview_item_->setPath( | |||
| makeConnectionPath(node->outputPortScenePosition(), scene_position)); | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| void LogicEditorWidget::clearPreview() | |||
| { | |||
| if (preview_item_ != nullptr) | |||
| { | |||
| scene_->removeItem(preview_item_); | |||
| delete preview_item_; | |||
| preview_item_ = nullptr; | |||
| } | |||
| connecting_from_node_id_.clear(); | |||
| } | |||
| LogicEditorWidget::NodeItem *LogicEditorWidget::nodeAtPort( | |||
| const QPointF &scene_position, bool output) const | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| NodeItem *node = dynamic_cast<NodeItem *>(item); | |||
| if (node == nullptr || (output && !node->hasOutput())) | |||
| { | |||
| continue; | |||
| } | |||
| const QPointF port = output | |||
| ? node->outputPortScenePosition() | |||
| : node->inputPortScenePosition(); | |||
| if (QLineF(port, scene_position).length() <= 12.0) | |||
| { | |||
| return node; | |||
| } | |||
| } | |||
| return nullptr; | |||
| } | |||
| LogicPoint LogicEditorWidget::nextNodePosition() const | |||
| { | |||
| const ControlLogic *logic = editor_service_.findLogic(logic_id_); | |||
| for (std::size_t index = 0;; ++index) | |||
| { | |||
| 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; | |||
| } | |||
| } | |||
| } | |||
| void LogicEditorWidget::reportFailure(const LogicEditorResult &result) | |||
| { | |||
| emit editorError(QString::fromStdString(result.message)); | |||
| } | |||
| @@ -0,0 +1,64 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| class LogicEditorService; | |||
| struct LogicEditorResult; | |||
| class QGraphicsPathItem; | |||
| class QGraphicsScene; | |||
| class QMouseEvent; | |||
| class QResizeEvent; | |||
| class LogicEditorWidget final : public QGraphicsView | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| explicit LogicEditorWidget( | |||
| LogicEditorService &editor_service, | |||
| QWidget *parent = nullptr); | |||
| void setLogicId(const std::string &logic_id); | |||
| void setEditingEnabled(bool enabled); | |||
| void reloadLogic(); | |||
| void selectNode(const std::string &node_id); | |||
| std::string selectedNodeId() const; | |||
| LogicEditorResult addNode(const LogicNodeConfig &config); | |||
| LogicEditorResult deleteSelected(); | |||
| signals: | |||
| void nodeSelected(const QString &node_id); | |||
| void nodeChanged(const QString &node_id); | |||
| void graphChanged(); | |||
| void editorError(const QString &message); | |||
| protected: | |||
| void mousePressEvent(QMouseEvent *event) override; | |||
| void mouseMoveEvent(QMouseEvent *event) override; | |||
| void mouseReleaseEvent(QMouseEvent *event) override; | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| private: | |||
| class NodeItem; | |||
| class ConnectionItem; | |||
| 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); | |||
| LogicEditorService &editor_service_; | |||
| QGraphicsScene *scene_ = nullptr; | |||
| std::string logic_id_; | |||
| bool editing_enabled_ = true; | |||
| std::string connecting_from_node_id_; | |||
| QGraphicsPathItem *preview_item_ = nullptr; | |||
| }; | |||
| @@ -1,8 +1,10 @@ | |||
| #include "main_window.h" | |||
| #include "hmi_editor_widget.h" | |||
| #include "logic_editor_widget.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "ui_main_window.h" | |||
| @@ -18,13 +20,19 @@ | |||
| #include <QMessageBox> | |||
| #include <QPushButton> | |||
| #include <QSpinBox> | |||
| #include <QStackedWidget> | |||
| #include <QStatusBar> | |||
| #include <QStyle> | |||
| #include <QTimer> | |||
| #include <QToolBar> | |||
| #include <QTabWidget> | |||
| #include <QTreeWidget> | |||
| #include <QTreeWidgetItem> | |||
| #include <cstdint> | |||
| #include <limits> | |||
| #include <type_traits> | |||
| namespace { | |||
| QString modeText(ApplicationMode mode) | |||
| @@ -124,6 +132,7 @@ MainWindow::MainWindow( | |||
| RuntimeModeService &runtime_mode_service, | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| QWidget *parent) | |||
| : QMainWindow(parent), | |||
| @@ -131,6 +140,7 @@ MainWindow::MainWindow( | |||
| runtime_mode_service_(runtime_mode_service), | |||
| project_service_(project_service), | |||
| hmi_editor_service_(hmi_editor_service), | |||
| logic_editor_service_(logic_editor_service), | |||
| hmi_runtime_service_(hmi_runtime_service) | |||
| { | |||
| ui_->setupUi(this); | |||
| @@ -138,7 +148,9 @@ MainWindow::MainWindow( | |||
| configureActions(); // 创建所有菜单 Action、工具栏按钮,绑定点击槽函数 | |||
| configurePropertyEditor(); // 动态构建属性表单:ID、文本、坐标宽高、寄存器区域 (M/D)、地址索引 + 应用按钮 | |||
| configureHmiEditor(); // 创建画布控件HmiEditorWidget嵌入主窗口,绑定画布信号:选中控件、控件拖动修改、编辑器报错;同时启动运行刷新定时器 | |||
| configureLogicEditor(); | |||
| hmi_editor_service_.ensureDefaultPage(); | |||
| logic_editor_service_.ensureDefaultLogic(); | |||
| refreshProjectUi(); | |||
| updateModeUi(tr("系统已进入编辑态")); | |||
| } | |||
| @@ -216,10 +228,112 @@ void MainWindow::configureActions() | |||
| connect(delete_control_action_, &QAction::triggered, | |||
| this, &MainWindow::deleteSelectedControl); | |||
| logic_tool_bar_ = addToolBar(tr("控制逻辑节点")); | |||
| logic_tool_bar_->setObjectName(QStringLiteral("logicToolBar")); | |||
| logic_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); | |||
| add_normally_open_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_ArrowRight), tr("常开")); | |||
| add_normally_closed_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_ArrowRight), tr("常闭")); | |||
| add_normal_coil_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_DialogApplyButton), tr("线圈")); | |||
| add_set_coil_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_DialogYesButton), tr("置位")); | |||
| add_reset_coil_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_DialogNoButton), tr("复位")); | |||
| add_compare_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("D 比较")); | |||
| delete_logic_action_ = logic_tool_bar_->addAction( | |||
| style()->standardIcon(QStyle::SP_TrashIcon), tr("删除")); | |||
| add_normally_open_action_->setObjectName(QStringLiteral("addNormallyOpenAction")); | |||
| add_normally_closed_action_->setObjectName(QStringLiteral("addNormallyClosedAction")); | |||
| add_normal_coil_action_->setObjectName(QStringLiteral("addNormalCoilAction")); | |||
| add_set_coil_action_->setObjectName(QStringLiteral("addSetCoilAction")); | |||
| add_reset_coil_action_->setObjectName(QStringLiteral("addResetCoilAction")); | |||
| add_compare_action_->setObjectName(QStringLiteral("addCompareAction")); | |||
| delete_logic_action_->setObjectName(QStringLiteral("deleteLogicAction")); | |||
| add_normally_open_action_->setToolTip(tr("添加常开触点")); | |||
| add_normally_closed_action_->setToolTip(tr("添加常闭触点")); | |||
| add_normal_coil_action_->setToolTip(tr("添加普通线圈")); | |||
| add_set_coil_action_->setToolTip(tr("添加置位线圈")); | |||
| add_reset_coil_action_->setToolTip(tr("添加复位线圈")); | |||
| add_compare_action_->setToolTip(tr("添加 D 值与常量比较")); | |||
| delete_logic_action_->setToolTip(tr("删除选中的逻辑节点或连接")); | |||
| connect(add_normally_open_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}); | |||
| }); | |||
| connect(add_normally_closed_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyClosed}); | |||
| }); | |||
| connect(add_normal_coil_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| CoilMode::Normal}); | |||
| }); | |||
| connect(add_set_coil_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| CoilMode::Set}); | |||
| }); | |||
| connect(add_reset_coil_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| CoilMode::Reset}); | |||
| }); | |||
| connect(add_compare_action_, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| { | |||
| addLogicNode(CompareNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, 0}, | |||
| ComparisonOperator::Equal, | |||
| 0}); | |||
| }); | |||
| connect(delete_logic_action_, &QAction::triggered, | |||
| this, &MainWindow::deleteSelectedLogicObject); | |||
| connect(ui_->editorTabWidget, &QTabWidget::currentChanged, | |||
| this, | |||
| [this](int index) | |||
| { | |||
| hmi_tool_bar_->setVisible(index == 0); | |||
| logic_tool_bar_->setVisible(index == 1); | |||
| if (index == 0) | |||
| { | |||
| showControlProperties(selected_control_id_); | |||
| } | |||
| else | |||
| { | |||
| showLogicNodeProperties(selected_logic_node_id_); | |||
| } | |||
| }); | |||
| hmi_tool_bar_->setVisible(ui_->editorTabWidget->currentIndex() == 0); | |||
| logic_tool_bar_->setVisible(ui_->editorTabWidget->currentIndex() == 1); | |||
| ui_->viewMenu->addAction(ui_->projectDock->toggleViewAction()); | |||
| ui_->viewMenu->addAction(ui_->propertiesDock->toggleViewAction()); | |||
| ui_->viewMenu->addAction(ui_->outputDock->toggleViewAction()); | |||
| ui_->viewMenu->addAction(hmi_tool_bar_->toggleViewAction()); | |||
| ui_->viewMenu->addAction(logic_tool_bar_->toggleViewAction()); | |||
| ui_->viewMenu->addSeparator(); | |||
| ui_->viewMenu->addAction(ui_->modeToolBar->toggleViewAction()); | |||
| } | |||
| @@ -306,6 +420,38 @@ void MainWindow::configureHmiEditor() | |||
| runtime_refresh_timer_->start(); | |||
| } | |||
| void MainWindow::configureLogicEditor() | |||
| { | |||
| QLayout *layout = ui_->logicCanvasPlaceholder->layout(); | |||
| delete ui_->logicEmptyLabel; | |||
| logic_editor_widget_ = new LogicEditorWidget( | |||
| logic_editor_service_, ui_->logicCanvasPlaceholder); | |||
| logic_editor_widget_->setObjectName(QStringLiteral("logicEditorWidget")); | |||
| logic_editor_widget_->setMinimumHeight(320); | |||
| layout->addWidget(logic_editor_widget_); | |||
| connect(logic_editor_widget_, &LogicEditorWidget::nodeSelected, | |||
| this, | |||
| [this](const QString &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, | |||
| this, &MainWindow::refreshProjectUi); | |||
| connect(logic_editor_widget_, &LogicEditorWidget::editorError, | |||
| this, | |||
| [this](const QString &message) | |||
| { | |||
| statusBar()->showMessage(message, 5000); | |||
| }); | |||
| } | |||
| void MainWindow::configurePropertyEditor() | |||
| { | |||
| control_id_edit_ = new QLineEdit(ui_->propertiesPage); | |||
| @@ -337,18 +483,75 @@ void MainWindow::configurePropertyEditor() | |||
| binding_index_spin_box_->setRange(0, RegisterAddress::kMaximumIndex); | |||
| apply_properties_button_ = new QPushButton(tr("应用属性"), ui_->propertiesPage); | |||
| apply_properties_button_->setObjectName(QStringLiteral("applyPropertiesButton")); | |||
| QFormLayout *form = ui_->propertiesForm; | |||
| form->addRow(tr("控件 ID"), control_id_edit_); | |||
| form->addRow(tr("显示文本"), control_text_edit_); | |||
| form->addRow(tr("X"), control_x_spin_box_); | |||
| form->addRow(tr("Y"), control_y_spin_box_); | |||
| form->addRow(tr("宽度"), control_width_spin_box_); | |||
| form->addRow(tr("高度"), control_height_spin_box_); | |||
| form->addRow(tr("绑定区域"), binding_area_combo_box_); | |||
| form->addRow(tr("绑定地址"), binding_index_spin_box_); | |||
| form->addRow(apply_properties_button_); | |||
| property_stack_ = new QStackedWidget(ui_->propertiesPage); | |||
| property_stack_->setObjectName(QStringLiteral("propertyStack")); | |||
| hmi_properties_page_ = new QWidget(property_stack_); | |||
| auto *hmi_form = new QFormLayout(hmi_properties_page_); | |||
| hmi_form->setContentsMargins(0, 0, 0, 0); | |||
| hmi_form->addRow(tr("控件 ID"), control_id_edit_); | |||
| hmi_form->addRow(tr("显示文本"), control_text_edit_); | |||
| hmi_form->addRow(tr("X"), control_x_spin_box_); | |||
| hmi_form->addRow(tr("Y"), control_y_spin_box_); | |||
| hmi_form->addRow(tr("宽度"), control_width_spin_box_); | |||
| hmi_form->addRow(tr("高度"), control_height_spin_box_); | |||
| hmi_form->addRow(tr("绑定区域"), binding_area_combo_box_); | |||
| hmi_form->addRow(tr("绑定地址"), binding_index_spin_box_); | |||
| hmi_form->addRow(apply_properties_button_); | |||
| logic_properties_page_ = new QWidget(property_stack_); | |||
| auto *logic_form = new QFormLayout(logic_properties_page_); | |||
| logic_form->setContentsMargins(0, 0, 0, 0); | |||
| logic_node_id_edit_ = new QLineEdit(logic_properties_page_); | |||
| logic_node_id_edit_->setObjectName(QStringLiteral("logicNodeIdEdit")); | |||
| logic_node_id_edit_->setReadOnly(true); | |||
| logic_node_type_label_ = new QLabel(logic_properties_page_); | |||
| logic_node_type_label_->setObjectName(QStringLiteral("logicNodeTypeLabel")); | |||
| logic_address_area_label_ = new QLabel(logic_properties_page_); | |||
| logic_address_area_label_->setObjectName(QStringLiteral("logicAddressAreaLabel")); | |||
| logic_address_spin_box_ = new QSpinBox(logic_properties_page_); | |||
| logic_address_spin_box_->setObjectName(QStringLiteral("logicAddressSpinBox")); | |||
| logic_address_spin_box_->setRange(0, RegisterAddress::kMaximumIndex); | |||
| logic_mode_combo_box_ = new QComboBox(logic_properties_page_); | |||
| logic_mode_combo_box_->setObjectName(QStringLiteral("logicModeComboBox")); | |||
| logic_comparison_combo_box_ = new QComboBox(logic_properties_page_); | |||
| logic_comparison_combo_box_->setObjectName(QStringLiteral("logicComparisonComboBox")); | |||
| logic_comparison_combo_box_->addItem( | |||
| tr("等于"), static_cast<int>(ComparisonOperator::Equal)); | |||
| logic_comparison_combo_box_->addItem( | |||
| tr("不等于"), static_cast<int>(ComparisonOperator::NotEqual)); | |||
| logic_comparison_combo_box_->addItem( | |||
| tr("小于"), static_cast<int>(ComparisonOperator::LessThan)); | |||
| logic_comparison_combo_box_->addItem( | |||
| tr("小于等于"), static_cast<int>(ComparisonOperator::LessThanOrEqual)); | |||
| logic_comparison_combo_box_->addItem( | |||
| tr("大于"), static_cast<int>(ComparisonOperator::GreaterThan)); | |||
| logic_comparison_combo_box_->addItem( | |||
| tr("大于等于"), static_cast<int>(ComparisonOperator::GreaterThanOrEqual)); | |||
| logic_value_spin_box_ = new QSpinBox(logic_properties_page_); | |||
| logic_value_spin_box_->setObjectName(QStringLiteral("logicValueSpinBox")); | |||
| logic_value_spin_box_->setRange( | |||
| std::numeric_limits<std::int16_t>::min(), | |||
| std::numeric_limits<std::int16_t>::max()); | |||
| apply_logic_properties_button_ = new QPushButton( | |||
| tr("应用属性"), logic_properties_page_); | |||
| apply_logic_properties_button_->setObjectName( | |||
| QStringLiteral("applyLogicPropertiesButton")); | |||
| logic_form->addRow(tr("节点 ID"), logic_node_id_edit_); | |||
| logic_form->addRow(tr("节点类型"), logic_node_type_label_); | |||
| logic_form->addRow(tr("地址区域"), logic_address_area_label_); | |||
| logic_form->addRow(tr("地址"), logic_address_spin_box_); | |||
| logic_form->addRow(tr("工作方式"), logic_mode_combo_box_); | |||
| logic_form->addRow(tr("比较运算"), logic_comparison_combo_box_); | |||
| logic_form->addRow(tr("比较常量"), logic_value_spin_box_); | |||
| logic_form->addRow(apply_logic_properties_button_); | |||
| property_stack_->addWidget(hmi_properties_page_); | |||
| property_stack_->addWidget(logic_properties_page_); | |||
| ui_->propertiesForm->addRow(property_stack_); | |||
| connect(apply_properties_button_, &QPushButton::clicked, | |||
| this, &MainWindow::applySelectedControlProperties); | |||
| connect(apply_logic_properties_button_, &QPushButton::clicked, | |||
| this, &MainWindow::applySelectedLogicNodeProperties); | |||
| showControlProperties({}); | |||
| } | |||
| @@ -361,6 +564,11 @@ void MainWindow::refreshProjectUi() | |||
| hmi_editor_widget_->setPageId(current_page_id); | |||
| } | |||
| const HmiPage *page = hmi_editor_service_.findPage(current_page_id); | |||
| const std::string current_logic_id = logic_editor_service_.firstLogicId(); | |||
| if (logic_editor_widget_ != nullptr) | |||
| { | |||
| logic_editor_widget_->setLogicId(current_logic_id); | |||
| } | |||
| ui_->projectTree->clear(); | |||
| auto *hmi_root = new QTreeWidgetItem(ui_->projectTree, {tr("HMI 页面")}); | |||
| if (page != nullptr) | |||
| @@ -375,13 +583,20 @@ void MainWindow::refreshProjectUi() | |||
| ui_->hmiPageTitleLabel->setText(tr("尚未创建 HMI 页面")); | |||
| ui_->hmiPageSizeLabel->clear(); | |||
| } | |||
| new QTreeWidgetItem(ui_->projectTree, {tr("控制逻辑")}); | |||
| auto *logic_root = new QTreeWidgetItem(ui_->projectTree, {tr("控制逻辑")}); | |||
| for (const ControlLogic &logic : project_service_.project().controlLogics) | |||
| { | |||
| const QString suffix = logic.enabled ? QString{} : tr("(已禁用)"); | |||
| logic_root->addChild(new QTreeWidgetItem( | |||
| logic_root, {fromUtf8(logic.name) + suffix})); | |||
| } | |||
| ui_->projectTree->expandAll(); | |||
| } | |||
| // 根据控件ID加载控件属性到右侧属性面板 | |||
| void MainWindow::showControlProperties(const std::string &control_id) | |||
| { | |||
| property_stack_->setCurrentWidget(hmi_properties_page_); | |||
| // 更新窗口全局状态:记录当前选中控件ID | |||
| selected_control_id_ = control_id; | |||
| @@ -439,6 +654,86 @@ void MainWindow::showControlProperties(const std::string &control_id) | |||
| control->binding.has_value() ? control->binding->index() : 0); | |||
| } | |||
| void MainWindow::showLogicNodeProperties(const std::string &node_id) | |||
| { | |||
| property_stack_->setCurrentWidget(logic_properties_page_); | |||
| selected_logic_node_id_ = node_id; | |||
| const LogicNode *node = logic_editor_service_.findNode( | |||
| logic_editor_service_.firstLogicId(), node_id); | |||
| const bool has_node = node != nullptr; | |||
| ui_->selectionValueLabel->setText( | |||
| has_node ? fromUtf8(node->id) : tr("未选择")); | |||
| for (QWidget *widget : {static_cast<QWidget *>(logic_node_id_edit_), | |||
| static_cast<QWidget *>(logic_address_spin_box_), | |||
| static_cast<QWidget *>(logic_mode_combo_box_), | |||
| static_cast<QWidget *>(logic_comparison_combo_box_), | |||
| static_cast<QWidget *>(logic_value_spin_box_), | |||
| static_cast<QWidget *>(apply_logic_properties_button_)}) | |||
| { | |||
| widget->setEnabled(has_node); | |||
| } | |||
| logic_node_type_label_->setEnabled(has_node); | |||
| logic_address_area_label_->setEnabled(has_node); | |||
| if (!has_node) | |||
| { | |||
| logic_node_id_edit_->clear(); | |||
| logic_node_type_label_->clear(); | |||
| logic_address_area_label_->clear(); | |||
| logic_mode_combo_box_->clear(); | |||
| return; | |||
| } | |||
| logic_node_id_edit_->setText(fromUtf8(node->id)); | |||
| logic_mode_combo_box_->clear(); | |||
| if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) | |||
| { | |||
| logic_node_type_label_->setText(tr("触点")); | |||
| logic_address_area_label_->setText(QStringLiteral("M")); | |||
| logic_address_spin_box_->setValue(contact->address.index()); | |||
| logic_mode_combo_box_->addItem( | |||
| tr("常开"), static_cast<int>(ContactMode::NormallyOpen)); | |||
| logic_mode_combo_box_->addItem( | |||
| tr("常闭"), static_cast<int>(ContactMode::NormallyClosed)); | |||
| logic_mode_combo_box_->setCurrentIndex( | |||
| logic_mode_combo_box_->findData(static_cast<int>(contact->mode))); | |||
| logic_mode_combo_box_->setEnabled(true); | |||
| logic_comparison_combo_box_->setEnabled(false); | |||
| logic_value_spin_box_->setEnabled(false); | |||
| } | |||
| else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config)) | |||
| { | |||
| logic_node_type_label_->setText(tr("线圈")); | |||
| logic_address_area_label_->setText(QStringLiteral("M")); | |||
| logic_address_spin_box_->setValue(coil->address.index()); | |||
| logic_mode_combo_box_->addItem( | |||
| tr("普通"), static_cast<int>(CoilMode::Normal)); | |||
| logic_mode_combo_box_->addItem( | |||
| tr("置位"), static_cast<int>(CoilMode::Set)); | |||
| logic_mode_combo_box_->addItem( | |||
| tr("复位"), static_cast<int>(CoilMode::Reset)); | |||
| logic_mode_combo_box_->setCurrentIndex( | |||
| logic_mode_combo_box_->findData(static_cast<int>(coil->mode))); | |||
| logic_mode_combo_box_->setEnabled(true); | |||
| logic_comparison_combo_box_->setEnabled(false); | |||
| logic_value_spin_box_->setEnabled(false); | |||
| } | |||
| else | |||
| { | |||
| const auto &compare = std::get<CompareNodeConfig>(node->config); | |||
| logic_node_type_label_->setText(tr("D 值比较")); | |||
| logic_address_area_label_->setText(QStringLiteral("D")); | |||
| logic_address_spin_box_->setValue(compare.address.index()); | |||
| logic_mode_combo_box_->addItem(tr("不适用")); | |||
| logic_mode_combo_box_->setEnabled(false); | |||
| logic_comparison_combo_box_->setEnabled(true); | |||
| logic_comparison_combo_box_->setCurrentIndex( | |||
| logic_comparison_combo_box_->findData( | |||
| static_cast<int>(compare.comparison))); | |||
| logic_value_spin_box_->setEnabled(true); | |||
| logic_value_spin_box_->setValue(compare.value); | |||
| } | |||
| } | |||
| void MainWindow::addHmiControl(HmiControlType type) | |||
| { | |||
| const std::string page_id = hmi_editor_service_.firstPageId(); | |||
| @@ -507,6 +802,78 @@ void MainWindow::applySelectedControlProperties() | |||
| statusBar()->showMessage(tr("控件属性已更新"), 3000); | |||
| } | |||
| void MainWindow::addLogicNode(const LogicNodeConfig &config) | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->addNode(config); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("添加逻辑节点"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_ = result.id; | |||
| showLogicNodeProperties(result.id); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已添加逻辑节点"), 3000); | |||
| } | |||
| void MainWindow::deleteSelectedLogicObject() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->deleteSelected(); | |||
| if (!result.succeeded) | |||
| { | |||
| return; | |||
| } | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| } | |||
| void MainWindow::applySelectedLogicNodeProperties() | |||
| { | |||
| const std::string logic_id = logic_editor_service_.firstLogicId(); | |||
| const LogicNode *node = logic_editor_service_.findNode( | |||
| logic_id, selected_logic_node_id_); | |||
| if (node == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| LogicNodeConfig config = node->config; | |||
| if (std::holds_alternative<ContactNodeConfig>(config)) | |||
| { | |||
| config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, logic_address_spin_box_->value()}, | |||
| static_cast<ContactMode>(logic_mode_combo_box_->currentData().toInt())}; | |||
| } | |||
| else if (std::holds_alternative<CoilNodeConfig>(config)) | |||
| { | |||
| config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, logic_address_spin_box_->value()}, | |||
| static_cast<CoilMode>(logic_mode_combo_box_->currentData().toInt())}; | |||
| } | |||
| else | |||
| { | |||
| config = CompareNodeConfig{ | |||
| RegisterAddress{RegisterArea::D, logic_address_spin_box_->value()}, | |||
| static_cast<ComparisonOperator>( | |||
| logic_comparison_combo_box_->currentData().toInt()), | |||
| static_cast<std::int16_t>(logic_value_spin_box_->value())}; | |||
| } | |||
| const LogicEditorResult result = logic_editor_service_.updateNodeConfig( | |||
| logic_id, selected_logic_node_id_, config); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("应用逻辑属性"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| logic_editor_widget_->reloadLogic(); | |||
| logic_editor_widget_->selectNode(selected_logic_node_id_); | |||
| showLogicNodeProperties(selected_logic_node_id_); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("逻辑节点属性已更新"), 3000); | |||
| } | |||
| void MainWindow::createNewProject() | |||
| { | |||
| const QString name = QInputDialog::getText( | |||
| @@ -522,9 +889,12 @@ void MainWindow::createNewProject() | |||
| return; | |||
| } | |||
| hmi_editor_service_.ensureDefaultPage(); | |||
| logic_editor_service_.ensureDefaultLogic(); | |||
| selected_control_id_.clear(); | |||
| selected_logic_node_id_.clear(); | |||
| refreshProjectUi(); | |||
| hmi_editor_widget_->reloadPage(); | |||
| logic_editor_widget_->reloadLogic(); | |||
| showControlProperties({}); | |||
| statusBar()->showMessage(tr("已创建新工程"), 3000); | |||
| } | |||
| @@ -570,8 +940,10 @@ void MainWindow::loadProject() | |||
| return; | |||
| } | |||
| selected_control_id_.clear(); | |||
| selected_logic_node_id_.clear(); | |||
| refreshProjectUi(); | |||
| hmi_editor_widget_->reloadPage(); | |||
| logic_editor_widget_->reloadLogic(); | |||
| showControlProperties({}); | |||
| showProjectResult(tr("加载工程"), tr("工程已加载"), true); | |||
| } | |||
| @@ -636,12 +1008,21 @@ void MainWindow::updateModeUi(const QString &message) | |||
| ui_->propertiesDock->setEnabled(policy.allowsProjectEditing); | |||
| hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | |||
| hmi_editor_widget_->setRuntimeActive(policy.usesVirtualRegisters); | |||
| logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | |||
| hmi_tool_bar_->setEnabled(policy.allowsProjectEditing); | |||
| logic_tool_bar_->setEnabled(policy.allowsProjectEditing); | |||
| add_button_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_indicator_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_numeric_display_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_numeric_input_action_->setEnabled(policy.allowsProjectEditing); | |||
| delete_control_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_normally_open_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_normally_closed_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_normal_coil_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_set_coil_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_reset_coil_action_->setEnabled(policy.allowsProjectEditing); | |||
| add_compare_action_->setEnabled(policy.allowsProjectEditing); | |||
| delete_logic_action_->setEnabled(policy.allowsProjectEditing); | |||
| new_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| save_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| save_as_project_action_->setEnabled(policy.allowsProjectEditing); | |||
| @@ -9,6 +9,7 @@ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include "domain/control_logic_model.h" | |||
| #include "domain/runtime_state.h" | |||
| #include <QMainWindow> | |||
| @@ -23,6 +24,7 @@ class QLabel; | |||
| class QLineEdit; | |||
| class QPushButton; | |||
| class QSpinBox; | |||
| class QStackedWidget; | |||
| class QTimer; | |||
| class QToolBar; | |||
| @@ -36,6 +38,8 @@ class ProjectService; | |||
| class HmiEditorService; | |||
| class HmiRuntimeService; | |||
| class HmiEditorWidget; | |||
| class LogicEditorService; | |||
| class LogicEditorWidget; | |||
| /** | |||
| * @brief 组织主界面区域并将用户操作转交给服务层 | |||
| @@ -59,6 +63,7 @@ public: | |||
| RuntimeModeService &runtime_mode_service, | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| QWidget *parent = nullptr); | |||
| @@ -71,18 +76,28 @@ private: | |||
| void configureAppearance(); | |||
| // 创建并连接 HMI 编辑画布 | |||
| void configureHmiEditor(); | |||
| // 创建并连接控制逻辑编辑画布 | |||
| void configureLogicEditor(); | |||
| // 创建并连接控件属性编辑表单 | |||
| void configurePropertyEditor(); | |||
| // 刷新工程树和当前 HMI 页面信息 | |||
| void refreshProjectUi(); | |||
| // 显示指定 HMI 控件的可编辑属性 | |||
| void showControlProperties(const std::string &control_id); | |||
| // 显示指定控制逻辑节点的可编辑属性 | |||
| void showLogicNodeProperties(const std::string &node_id); | |||
| // 向当前页面添加指定类型的 HMI 控件 | |||
| void addHmiControl(HmiControlType type); | |||
| // 删除当前选中的 HMI 控件 | |||
| void deleteSelectedControl(); | |||
| // 将属性表单内容应用到当前选中控件 | |||
| void applySelectedControlProperties(); | |||
| // 向当前控制逻辑添加指定配置的节点 | |||
| void addLogicNode(const LogicNodeConfig &config); | |||
| // 删除当前选中的逻辑节点或连接 | |||
| void deleteSelectedLogicObject(); | |||
| // 将逻辑属性表单内容应用到当前选中节点 | |||
| void applySelectedLogicNodeProperties(); | |||
| // 创建新的工程并刷新编辑界面 | |||
| void createNewProject(); | |||
| // 保存当前工程到已关联的路径 | |||
| @@ -123,6 +138,7 @@ private: | |||
| */ | |||
| ProjectService &project_service_; | |||
| HmiEditorService &hmi_editor_service_; | |||
| LogicEditorService &logic_editor_service_; | |||
| HmiRuntimeService &hmi_runtime_service_; | |||
| QActionGroup *mode_action_group_ = nullptr; | |||
| QAction *new_project_action_ = nullptr; | |||
| @@ -134,8 +150,17 @@ private: | |||
| QAction *add_numeric_display_action_ = nullptr; | |||
| QAction *add_numeric_input_action_ = nullptr; | |||
| QAction *delete_control_action_ = nullptr; | |||
| QAction *add_normally_open_action_ = nullptr; | |||
| QAction *add_normally_closed_action_ = nullptr; | |||
| QAction *add_normal_coil_action_ = nullptr; | |||
| QAction *add_set_coil_action_ = nullptr; | |||
| QAction *add_reset_coil_action_ = nullptr; | |||
| QAction *add_compare_action_ = nullptr; | |||
| QAction *delete_logic_action_ = nullptr; | |||
| QToolBar *hmi_tool_bar_ = nullptr; | |||
| QToolBar *logic_tool_bar_ = nullptr; | |||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | |||
| LogicEditorWidget *logic_editor_widget_ = nullptr; | |||
| QTimer *runtime_refresh_timer_ = nullptr; | |||
| QLabel *mode_status_label_ = nullptr; | |||
| QLabel *register_status_label_ = nullptr; | |||
| @@ -149,5 +174,17 @@ private: | |||
| QComboBox *binding_area_combo_box_ = nullptr; | |||
| QSpinBox *binding_index_spin_box_ = nullptr; | |||
| QPushButton *apply_properties_button_ = nullptr; | |||
| QStackedWidget *property_stack_ = nullptr; | |||
| QWidget *hmi_properties_page_ = nullptr; | |||
| QWidget *logic_properties_page_ = nullptr; | |||
| QLineEdit *logic_node_id_edit_ = nullptr; | |||
| QLabel *logic_node_type_label_ = nullptr; | |||
| QLabel *logic_address_area_label_ = nullptr; | |||
| QSpinBox *logic_address_spin_box_ = nullptr; | |||
| QComboBox *logic_mode_combo_box_ = nullptr; | |||
| QComboBox *logic_comparison_combo_box_ = nullptr; | |||
| QSpinBox *logic_value_spin_box_ = nullptr; | |||
| QPushButton *apply_logic_properties_button_ = nullptr; | |||
| std::string selected_control_id_; | |||
| std::string selected_logic_node_id_; | |||
| }; | |||
| @@ -117,6 +117,54 @@ void testLogicNodeConfigurationBoundaries() | |||
| require(comparison.validate(), "comparison node bound to D address must be valid"); | |||
| } | |||
| void testLogicGraphBoundaries() | |||
| { | |||
| LogicNode start; | |||
| start.id = "start"; | |||
| start.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| LogicNode run_contact; | |||
| run_contact.id = "run-contact"; | |||
| run_contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| ContactMode::NormallyOpen}; | |||
| LogicNode coil; | |||
| coil.id = "run-coil"; | |||
| coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Normal}; | |||
| ControlLogic logic; | |||
| logic.id = "hold-logic"; | |||
| logic.name = "Hold logic"; | |||
| 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"); | |||
| } | |||
| void testModelsValidateBindingsAndIdentifiers() | |||
| { | |||
| // 聚合验证必须拒绝错误绑定、重复标识和越界控件 | |||
| @@ -177,6 +225,7 @@ int main() | |||
| testRegisterAddressBoundaries(); | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testLogicGraphBoundaries(); | |||
| testModelsValidateBindingsAndIdentifiers(); | |||
| testRuntimeStateBoundaries(); | |||
| } | |||
| @@ -0,0 +1,102 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include <exception> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| namespace { | |||
| class TestProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| ProjectSaveResult save(const Project &, const std::string &) override | |||
| { | |||
| return {true, ProjectStorageError::None, {}}; | |||
| } | |||
| ProjectLoadResult load(const std::string &) override | |||
| { | |||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||
| } | |||
| }; | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| void testEditorOperations() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const LogicEditorResult logic_result = service.ensureDefaultLogic(); | |||
| require(logic_result.succeeded, "default logic must be created"); | |||
| const std::string logic_id = logic_result.id; | |||
| const LogicEditorResult start_result = service.addNode( | |||
| logic_id, | |||
| ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}, | |||
| {80, 80}); | |||
| const LogicEditorResult coil_result = service.addNode( | |||
| logic_id, | |||
| CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Normal}, | |||
| {360, 80}); | |||
| require(start_result.succeeded && coil_result.succeeded, | |||
| "contact and coil must be added"); | |||
| require(service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded, | |||
| "valid nodes must connect"); | |||
| require(!service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded, | |||
| "duplicate node connections must fail"); | |||
| require(!service.connectNodes(logic_id, coil_result.id, start_result.id).succeeded, | |||
| "coil outgoing connection must fail"); | |||
| 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(service.updateNodeConfig( | |||
| logic_id, | |||
| start_result.id, | |||
| ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 2}, | |||
| ContactMode::NormallyClosed}) | |||
| .succeeded, | |||
| "contact properties must be editable"); | |||
| 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"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| { | |||
| try | |||
| { | |||
| testEditorOperations(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| std::cerr << "logic editor service tests failed: " << error.what() << '\n'; | |||
| return 1; | |||
| } | |||
| std::cout << "logic editor service tests passed\n"; | |||
| return 0; | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| TEMPLATE = app | |||
| TARGET = logic_editor_service_tests | |||
| CONFIG += console c++17 testcase warn_on | |||
| CONFIG -= app_bundle qt | |||
| INCLUDEPATH += ../src | |||
| SOURCES += \ | |||
| logic_editor_service_tests.cpp \ | |||
| ../src/domain/register_address.cpp \ | |||
| ../src/domain/control_logic_model.cpp \ | |||
| ../src/domain/project_model.cpp \ | |||
| ../src/domain/hmi_model.cpp \ | |||
| ../src/domain/register_repository.cpp \ | |||
| ../src/services/project_service.cpp \ | |||
| ../src/services/logic_editor_service.cpp | |||
| HEADERS += \ | |||
| ../src/domain/register_address.h \ | |||
| ../src/domain/control_logic_model.h \ | |||
| ../src/domain/project_model.h \ | |||
| ../src/domain/hmi_model.h \ | |||
| ../src/domain/register_repository.h \ | |||
| ../src/domain/project_storage.h \ | |||
| ../src/services/project_service.h \ | |||
| ../src/services/logic_editor_service.h | |||
| @@ -2,9 +2,11 @@ | |||
| #include "domain/register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "ui/hmi_editor_widget.h" | |||
| #include "ui/logic_editor_widget.h" | |||
| #include "ui/main_window.h" | |||
| #include <QAction> | |||
| @@ -57,11 +59,16 @@ void testModeActionsControlEditingAvailability() | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService editor_service(project_service); | |||
| LogicEditorService logic_editor_service(project_service); | |||
| VirtualRegisterRepository repository; | |||
| HmiRuntimeService runtime_service(repository); | |||
| RuntimeModeService mode_service; | |||
| MainWindow window( | |||
| mode_service, project_service, editor_service, runtime_service); | |||
| mode_service, | |||
| project_service, | |||
| editor_service, | |||
| logic_editor_service, | |||
| runtime_service); | |||
| window.resize(1000, 640); | |||
| window.show(); | |||
| QApplication::processEvents(); | |||
| @@ -71,6 +78,10 @@ void testModeActionsControlEditingAvailability() | |||
| QAction *online_action = requiredChild<QAction>(window, "onlineModeAction"); | |||
| QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction"); | |||
| QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction"); | |||
| QAction *add_normally_open_action = requiredChild<QAction>( | |||
| window, "addNormallyOpenAction"); | |||
| QAction *add_normal_coil_action = requiredChild<QAction>( | |||
| window, "addNormalCoilAction"); | |||
| QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock"); | |||
| QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | |||
| QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | |||
| @@ -100,6 +111,13 @@ void testModeActionsControlEditingAvailability() | |||
| require(editor_service.findPage(page_id)->controls.empty(), | |||
| "deleting a selected control must update the HMI page model"); | |||
| add_normally_open_action->trigger(); | |||
| add_normal_coil_action->trigger(); | |||
| const ControlLogic *logic = logic_editor_service.findLogic( | |||
| logic_editor_service.firstLogicId()); | |||
| require(logic != nullptr && logic->nodes.size() == 2, | |||
| "logic actions must add nodes through the logic editor service"); | |||
| offline_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | |||
| "offline action must enter offline running"); | |||
| @@ -109,6 +127,8 @@ void testModeActionsControlEditingAvailability() | |||
| "properties dock must be disabled while running"); | |||
| require(!add_button_action->isEnabled(), | |||
| "HMI add controls must be disabled while running"); | |||
| require(!add_normally_open_action->isEnabled(), | |||
| "logic add nodes must be disabled while running"); | |||
| online_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::OfflineRunning, | |||
| @@ -125,6 +145,8 @@ void testModeActionsControlEditingAvailability() | |||
| "properties dock must be restored after returning to editing"); | |||
| require(add_button_action->isEnabled(), | |||
| "HMI add controls must be restored after returning to editing"); | |||
| require(add_normally_open_action->isEnabled(), | |||
| "logic add nodes must be restored after returning to editing"); | |||
| online_action->trigger(); | |||
| require(mode_service.mode() == ApplicationMode::Editing, | |||
| @@ -12,6 +12,7 @@ SOURCES += \ | |||
| main_window_tests.cpp \ | |||
| ../src/ui/main_window.cpp \ | |||
| ../src/ui/hmi_editor_widget.cpp \ | |||
| ../src/ui/logic_editor_widget.cpp \ | |||
| ../src/domain/register_address.cpp \ | |||
| ../src/domain/register_repository.cpp \ | |||
| ../src/domain/hmi_model.cpp \ | |||
| @@ -20,12 +21,14 @@ SOURCES += \ | |||
| ../src/domain/runtime_state.cpp \ | |||
| ../src/services/project_service.cpp \ | |||
| ../src/services/hmi_editor_service.cpp \ | |||
| ../src/services/logic_editor_service.cpp \ | |||
| ../src/services/hmi_runtime_service.cpp \ | |||
| ../src/services/runtime_mode_service.cpp | |||
| HEADERS += \ | |||
| ../src/ui/main_window.h \ | |||
| ../src/ui/hmi_editor_widget.h \ | |||
| ../src/ui/logic_editor_widget.h \ | |||
| ../src/domain/register_address.h \ | |||
| ../src/domain/register_repository.h \ | |||
| ../src/domain/hmi_model.h \ | |||
| @@ -35,6 +38,7 @@ HEADERS += \ | |||
| ../src/domain/runtime_state.h \ | |||
| ../src/services/project_service.h \ | |||
| ../src/services/hmi_editor_service.h \ | |||
| ../src/services/logic_editor_service.h \ | |||
| ../src/services/hmi_runtime_service.h \ | |||
| ../src/services/runtime_mode_service.h | |||
| @@ -68,6 +68,7 @@ Project makeExampleProject() | |||
| contact.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| ContactMode::NormallyOpen}; | |||
| contact.position = {80, 100}; | |||
| LogicNode compare; | |||
| compare.id = "temperature-check"; | |||
| @@ -75,12 +76,14 @@ Project makeExampleProject() | |||
| RegisterAddress{RegisterArea::D, 2}, | |||
| ComparisonOperator::GreaterThanOrEqual, | |||
| static_cast<std::int16_t>(100)}; | |||
| compare.position = {320, 100}; | |||
| LogicNode coil; | |||
| coil.id = "run-coil"; | |||
| coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 1}, | |||
| CoilMode::Set}; | |||
| coil.position = {560, 100}; | |||
| ControlLogic logic; | |||
| logic.id = "start-logic"; | |||
| @@ -176,6 +179,8 @@ void testExampleProjectRoundTrip() | |||
| "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 auto &compare = std::get<CompareNodeConfig>( | |||
| project.controlLogics.front().nodes.at(1).config); | |||
| @@ -2,7 +2,7 @@ | |||
| - Goal: 完成综合平台编程器的工程管理和后续编辑功能。 | |||
| - Branch: `main`。 | |||
| - Current status: 已完成开发顺序第 6 步,HMI 编辑器、工程保存加载闭环和离线 HMI 寄存器适配已完成。 | |||
| - Current status: 已完成开发顺序第 7 步,控制逻辑图形编辑、领域校验和工程持久化闭环已完成。 | |||
| - Changed files: | |||
| - `app/integrated_platform.pro` | |||
| - `app/src/main.cpp` | |||
| @@ -52,6 +52,12 @@ | |||
| - `app/tests/main_window_tests.cpp` | |||
| - `app/src/ui/hmi_editor_widget.h` | |||
| - `app/src/ui/hmi_editor_widget.cpp` | |||
| - `app/src/services/logic_editor_service.h` | |||
| - `app/src/services/logic_editor_service.cpp` | |||
| - `app/src/ui/logic_editor_widget.h` | |||
| - `app/src/ui/logic_editor_widget.cpp` | |||
| - `app/tests/logic_editor_service_tests.pro` | |||
| - `app/tests/logic_editor_service_tests.cpp` | |||
| - Decisions made: | |||
| - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 | |||
| - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 | |||
| @@ -80,6 +86,12 @@ | |||
| - 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。 | |||
| - 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。 | |||
| - `HmiRuntimeService` 仅通过 `RegisterRepository` 访问寄存器,不依赖虚拟寄存器、串口或 Modbus。 | |||
| - 控制逻辑使用有向无环图;串联为 AND、多个前驱汇合为 OR、一个输出连接多个后继为并行分支。 | |||
| - 线圈只能作为终点,保存时要求线圈有输入且每个条件节点最终到达线圈。 | |||
| - 状态保持通过触点与线圈共享 M 地址表达,不创建图上的反馈环。 | |||
| - `LogicEditorService` 负责节点和连接编辑,删除节点会同时删除关联连接。 | |||
| - `LogicEditorWidget` 只保存节点 ID 和连接端点,不访问 HMI、寄存器或 PLC。 | |||
| - 节点位置保存在 `1.0` 工程文件中,并作为逻辑节点的必填工程字段。 | |||
| - Validation run and results: | |||
| - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | |||
| - 已启动 `integrated_platform.exe` 并正常退出。 | |||
| @@ -100,6 +112,9 @@ | |||
| - 在 `build/hmi-editor/` 重新构建 Release 应用,构建成功并完成启动冒烟检查。 | |||
| - 在 `build/hmi-editor-service-tests/` 构建并运行 HMI 服务测试,输出 `HMI editor service tests passed`。 | |||
| - 重新构建并运行领域、工程管理和主窗口测试,全部通过。 | |||
| - Remaining work: 进入开发顺序第 7 步,实现控制逻辑编辑器。 | |||
| - 控制逻辑编辑器完成后,领域、逻辑编辑服务、工程管理和主窗口测试全部通过。 | |||
| - Debug 应用构建成功。 | |||
| - 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。 | |||
| - Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。 | |||
| - Known risks / blockers: 无。 | |||
| - Suggested next command: 为控制逻辑建立独立图形编辑器和服务层编辑用例。 | |||
| - Suggested next command: 为控制逻辑建立固定周期的软件执行器并按拓扑顺序求值。 | |||
| @@ -84,6 +84,16 @@ main.cpp -> UI + Services + Infrastructure | |||
| 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | |||
| 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | |||
| 控制逻辑使用有向无环图表达。没有前驱的条件节点从隐式左母线取得真值,条件节点输出为 | |||
| 前驱输入与自身条件的逻辑与;多个前驱在汇合处执行逻辑或,一个输出连接多个后继表示并行 | |||
| 分支。线圈只能作为终点。状态保持通过触点和线圈绑定同一个 M 地址表达,不建立从线圈返回 | |||
| 触点的环形连接。上述语义是后续软件逻辑执行器的确定性求值契约。 | |||
| `services/LogicEditorService` 负责逻辑节点的添加、移动、属性更新、连接、断开和删除。编辑服务 | |||
| 只要求每次操作后的结构合法,允许用户暂时保留尚未连接完成的中间状态;工程保存仍调用完整 | |||
| 领域校验,拒绝无输入线圈和不能到达线圈的条件节点。`ui/LogicEditorWidget` 只投影节点标识和 | |||
| 连接端点,通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。节点画布位置随工程保存。 | |||
| ## 运行模式边界 | |||
| | 模式 | 允许编辑工程 | 寄存器来源 | 软件逻辑执行器 | 进入约束 | | |||