diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 991b718..a34c93a 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -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 diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index c8df7c7..a9ff78f 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -1,6 +1,8 @@ #include "control_logic_model.h" #include +#include +#include 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 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(node.config) + && incoming_count.at(node.id) == 0U) + { + setError(error, "coil node must have at least one input connection"); + return false; + } + } + + // 从所有线圈沿反向连接遍历,一次找出能够产生动作的全部条件节点 + std::unordered_map> incoming; + std::vector pending; + for (const LogicNode &node : nodes) + { + incoming.emplace(node.id, std::vector{}); + if (std::holds_alternative(node.config)) + { + pending.push_back(node.id); + } + } + for (const LogicConnection &connection : connections) + { + incoming.at(connection.toNodeId).push_back(connection.fromNodeId); + } + std::unordered_set 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> outgoing; + std::unordered_map incoming_count; + for (const LogicNode &node : nodes) + { + outgoing.emplace(node.id, std::vector{}); + 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(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 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; } diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index e77f7a3..43a6f4a 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -7,6 +7,18 @@ #include #include +// 逻辑节点在编辑画布中的持久化坐标 +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; }; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 43b706d..6b36573 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -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 对象 diff --git a/app/src/main.cpp b/app/src/main.cpp index 69178e2..54a6717 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -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(); diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp new file mode 100644 index 0000000..152d69c --- /dev/null +++ b/app/src/services/logic_editor_service.cpp @@ -0,0 +1,395 @@ +#include "logic_editor_service.h" + +#include "project_service.h" + +#include +#include +#include +#include + +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; + if constexpr (std::is_same_v) + { + return "contact"; + } + if constexpr (std::is_same_v) + { + 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, {}}; +} diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h new file mode 100644 index 0000000..1d3334f --- /dev/null +++ b/app/src/services/logic_editor_service.h @@ -0,0 +1,74 @@ +#pragma once + +#include "domain/control_logic_model.h" + +#include + +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_; +}; diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp new file mode 100644 index 0000000..dd2eba3 --- /dev/null +++ b/app/src/ui/logic_editor_widget.cpp @@ -0,0 +1,624 @@ +#include "logic_editor_widget.h" + +#include "services/logic_editor_service.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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; + if constexpr (std::is_same_v) + { + return value.mode == ContactMode::NormallyOpen + ? QStringLiteral("常开触点") + : QStringLiteral("常闭触点"); + } + if constexpr (std::is_same_v) + { + 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; + if constexpr (std::is_same_v) + { + return QStringLiteral("M%1").arg(value.address.index()); + } + else if constexpr (std::is_same_v) + { + 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(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 moved, + std::function 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 moved_; + std::function 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(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 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(std::lround(position.x())), + static_cast(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(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(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(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(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 node_items; + for (QGraphicsItem *item : scene_->items()) + { + NodeItem *node = dynamic_cast(item); + if (node != nullptr) + { + node_items.emplace(node->nodeId(), node); + } + } + for (QGraphicsItem *item : scene_->items()) + { + ConnectionItem *connection = dynamic_cast(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(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(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(index % 5U) * 230, + 80 + static_cast(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)); +} diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h new file mode 100644 index 0000000..5856dc2 --- /dev/null +++ b/app/src/ui/logic_editor_widget.h @@ -0,0 +1,64 @@ +#pragma once + +#include "domain/control_logic_model.h" + +#include + +#include + +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; +}; diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 9e6e01f..cdf434b 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -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 #include #include +#include #include #include #include #include +#include #include #include +#include +#include +#include + 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(ComparisonOperator::Equal)); + logic_comparison_combo_box_->addItem( + tr("不等于"), static_cast(ComparisonOperator::NotEqual)); + logic_comparison_combo_box_->addItem( + tr("小于"), static_cast(ComparisonOperator::LessThan)); + logic_comparison_combo_box_->addItem( + tr("小于等于"), static_cast(ComparisonOperator::LessThanOrEqual)); + logic_comparison_combo_box_->addItem( + tr("大于"), static_cast(ComparisonOperator::GreaterThan)); + logic_comparison_combo_box_->addItem( + tr("大于等于"), static_cast(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::min(), + std::numeric_limits::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(logic_node_id_edit_), + static_cast(logic_address_spin_box_), + static_cast(logic_mode_combo_box_), + static_cast(logic_comparison_combo_box_), + static_cast(logic_value_spin_box_), + static_cast(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(&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(ContactMode::NormallyOpen)); + logic_mode_combo_box_->addItem( + tr("常闭"), static_cast(ContactMode::NormallyClosed)); + logic_mode_combo_box_->setCurrentIndex( + logic_mode_combo_box_->findData(static_cast(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(&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(CoilMode::Normal)); + logic_mode_combo_box_->addItem( + tr("置位"), static_cast(CoilMode::Set)); + logic_mode_combo_box_->addItem( + tr("复位"), static_cast(CoilMode::Reset)); + logic_mode_combo_box_->setCurrentIndex( + logic_mode_combo_box_->findData(static_cast(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(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(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(config)) + { + config = ContactNodeConfig{ + RegisterAddress{RegisterArea::M, logic_address_spin_box_->value()}, + static_cast(logic_mode_combo_box_->currentData().toInt())}; + } + else if (std::holds_alternative(config)) + { + config = CoilNodeConfig{ + RegisterAddress{RegisterArea::M, logic_address_spin_box_->value()}, + static_cast(logic_mode_combo_box_->currentData().toInt())}; + } + else + { + config = CompareNodeConfig{ + RegisterAddress{RegisterArea::D, logic_address_spin_box_->value()}, + static_cast( + logic_comparison_combo_box_->currentData().toInt()), + static_cast(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); diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index a356fc7..5192bab 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -9,6 +9,7 @@ #pragma once #include "domain/hmi_model.h" +#include "domain/control_logic_model.h" #include "domain/runtime_state.h" #include @@ -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_; }; diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index 0dd8e10..12936e9 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -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(); } diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp new file mode 100644 index 0000000..e48ff54 --- /dev/null +++ b/app/tests/logic_editor_service_tests.cpp @@ -0,0 +1,102 @@ +#include "domain/project_storage.h" +#include "services/logic_editor_service.h" +#include "services/project_service.h" + +#include +#include +#include + +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; +} diff --git a/app/tests/logic_editor_service_tests.pro b/app/tests/logic_editor_service_tests.pro new file mode 100644 index 0000000..a37ee79 --- /dev/null +++ b/app/tests/logic_editor_service_tests.pro @@ -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 diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index c1d34cc..295bc1a 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -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 @@ -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(window, "onlineModeAction"); QAction *add_button_action = requiredChild(window, "addButtonAction"); QAction *delete_control_action = requiredChild(window, "deleteControlAction"); + QAction *add_normally_open_action = requiredChild( + window, "addNormallyOpenAction"); + QAction *add_normal_coil_action = requiredChild( + window, "addNormalCoilAction"); QDockWidget *project_dock = requiredChild(window, "projectDock"); QDockWidget *properties_dock = requiredChild(window, "propertiesDock"); QLabel *selection = requiredChild(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, diff --git a/app/tests/main_window_tests.pro b/app/tests/main_window_tests.pro index 1717edb..da6884f 100644 --- a/app/tests/main_window_tests.pro +++ b/app/tests/main_window_tests.pro @@ -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 diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 281f607..3dcd6c4 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -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(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( project.controlLogics.front().nodes.at(1).config); diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md index a2e6579..92b4cd3 100644 --- a/docs/ai/handoff.md +++ b/docs/ai/handoff.md @@ -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: 为控制逻辑建立固定周期的软件执行器并按拓扑顺序求值。 diff --git a/docs/architecture.md b/docs/architecture.md index 4d12066..147c884 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,6 +84,16 @@ main.cpp -> UI + Services + Infrastructure 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 +控制逻辑使用有向无环图表达。没有前驱的条件节点从隐式左母线取得真值,条件节点输出为 +前驱输入与自身条件的逻辑与;多个前驱在汇合处执行逻辑或,一个输出连接多个后继表示并行 +分支。线圈只能作为终点。状态保持通过触点和线圈绑定同一个 M 地址表达,不建立从线圈返回 +触点的环形连接。上述语义是后续软件逻辑执行器的确定性求值契约。 + +`services/LogicEditorService` 负责逻辑节点的添加、移动、属性更新、连接、断开和删除。编辑服务 +只要求每次操作后的结构合法,允许用户暂时保留尚未连接完成的中间状态;工程保存仍调用完整 +领域校验,拒绝无输入线圈和不能到达线圈的条件节点。`ui/LogicEditorWidget` 只投影节点标识和 +连接端点,通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。节点画布位置随工程保存。 + ## 运行模式边界 | 模式 | 允许编辑工程 | 寄存器来源 | 软件逻辑执行器 | 进入约束 |