diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 27cca5d..da9b7b2 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -1,10 +1,10 @@ #include "control_logic_model.h" -#include "project_limits.h" - #include +#include #include -#include +#include +#include namespace { @@ -16,23 +16,115 @@ void setError(std::string *error, const std::string &message) } } +bool isBlank(const std::string &value) +{ + return value.empty() + || std::all_of( + value.cbegin(), value.cend(), + [](unsigned char character) { return character <= 0x20U; }); +} + bool containsLineBreak(const std::string &value) { return value.find('\r') != std::string::npos || value.find('\n') != std::string::npos; } -bool validateConfig(const ContactNodeConfig &config, std::string *error) +bool validateOutputConnectivity( + const ControlLogic &logic, + std::string *error) { - if (!config.address.isValid() || config.address.area() != RegisterArea::M) + const std::size_t row_count = logic.rungs.size(); + if (row_count == 0U) { - setError(error, "触点节点必须使用有效的 M 区地址"); - return false; + return true; } - if (config.mode != ContactMode::NormallyOpen - && config.mode != ContactMode::NormallyClosed) + + std::unordered_map row_indices; + row_indices.reserve(row_count); + for (std::size_t row = 0U; row < row_count; ++row) { - setError(error, "触点节点使用了不支持的模式"); + row_indices.emplace(logic.rungs[row].id, row); + } + + std::vector power(row_count, true); + std::vector last_reachable_boundary(row_count, 0); + for (int boundary = 0; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) + { + std::vector parent(row_count); + std::iota(parent.begin(), parent.end(), 0U); + const auto root = [&parent](std::size_t row) + { + while (parent[row] != row) + { + row = parent[row]; + } + return row; + }; + const auto unite = [&parent, &root](std::size_t upper, std::size_t lower) + { + const std::size_t upper_root = root(upper); + const std::size_t lower_root = root(lower); + if (upper_root != lower_root) + { + parent[lower_root] = upper_root; + } + }; + for (const VerticalConnection &connection : logic.verticalConnections) + { + if (connection.columnBoundary != boundary) + { + continue; + } + unite( + row_indices.at(connection.upperRungId), + row_indices.at(connection.lowerRungId)); + } + + std::vector component_power(row_count, false); + for (std::size_t row = 0U; row < row_count; ++row) + { + const std::size_t component = root(row); + component_power[component] = component_power[component] || power[row]; + } + for (std::size_t row = 0U; row < row_count; ++row) + { + power[row] = component_power[root(row)]; + if (power[row]) + { + last_reachable_boundary[row] = boundary; + } + } + if (boundary == ProjectLimits::kMaximumConditionColumns) + { + break; + } + for (std::size_t row = 0U; row < row_count; ++row) + { + power[row] = power[row] + && logic.rungs[row].cells[static_cast(boundary)].kind + != LadderCellKind::Gap; + } + } + + for (std::size_t row = 0U; row < row_count; ++row) + { + const LadderRung &rung = logic.rungs[row]; + if (!rung.output.has_value() || power[row]) + { + continue; + } + const int disconnected_column = std::min( + last_reachable_boundary[row] + 1, + ProjectLimits::kMaximumConditionColumns); + setError( + error, + "控制逻辑 " + logic.name + " 的第 " + std::to_string(row + 1U) + + " 行(" + rung.name + ")输出路径从第 " + + std::to_string(disconnected_column) + + " 列起断开,未连接到左母线"); return false; } return true; @@ -51,6 +143,22 @@ bool validateWordAddress( return true; } +bool validateConfig(const ContactNodeConfig &config, std::string *error) +{ + if (!config.address.isValid() || config.address.area() != RegisterArea::M) + { + setError(error, "触点节点必须使用有效的 M 区地址"); + return false; + } + if (config.mode != ContactMode::NormallyOpen + && config.mode != ContactMode::NormallyClosed) + { + setError(error, "触点节点使用了不支持的模式"); + return false; + } + return true; +} + bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) { if (!config.address.isValid() || config.address.area() != RegisterArea::M) @@ -105,11 +213,8 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) bool validateConfig(const MoveNodeConfig &config, std::string *error) { - if (!config.source.validate(error)) - { - return false; - } - return validateWordAddress(config.destination, "MOVE 目标", error); + return config.source.validate(error) + && validateWordAddress(config.destination, "MOVE 目标", error); } bool validateConfig(const ArithmeticNodeConfig &config, std::string *error) @@ -120,86 +225,9 @@ bool validateConfig(const ArithmeticNodeConfig &config, std::string *error) setError(error, "算术指令使用了不支持的运算"); return false; } - if (!config.left.validate(error) || !config.right.validate(error)) - { - return false; - } - return validateWordAddress(config.destination, "算术指令目标", error); -} - -template -bool hasDuplicateId(const std::vector &items) -{ - for (auto current = items.cbegin(); current != items.cend(); ++current) - { - if (std::find_if( - current + 1, - items.cend(), - [¤t](const TItem &candidate) - { - return candidate.id == current->id; - }) != items.cend()) - { - return true; - } - } - return false; -} - -bool validateUniqueExpressionIds( - const ConditionExpression &expression, std::string *error) -{ - std::vector ids; - collectConditionExpressionIds(expression, &ids); - std::sort(ids.begin(), ids.end()); - if (std::adjacent_find(ids.cbegin(), ids.cend()) != ids.cend()) - { - setError(error, "同一网络内的条件表达式 ID 必须唯一"); - return false; - } - return true; -} - -void normalizeExpression(ConditionExpression *expression) -{ - if (expression == nullptr - || expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire - || expression->kind == ConditionExpressionKind::Gap) - { - return; - } - for (ConditionExpression &child : expression->children) - { - normalizeExpression(&child); - } - - std::vector flattened; - for (ConditionExpression &child : expression->children) - { - if ((child.kind == ConditionExpressionKind::Series - || child.kind == ConditionExpressionKind::Parallel) - && child.children.empty()) - { - continue; - } - if (child.kind == expression->kind) - { - for (ConditionExpression &grandchild : child.children) - { - flattened.push_back(std::move(grandchild)); - } - } - else - { - flattened.push_back(std::move(child)); - } - } - expression->children = std::move(flattened); - if (expression->children.size() == 1U) - { - *expression = std::move(expression->children.front()); - } + return config.left.validate(error) + && config.right.validate(error) + && validateWordAddress(config.destination, "算术指令目标", error); } void appendValidAddress( @@ -212,181 +240,20 @@ void appendValidAddress( } } -bool validateConditionExpression( - const ConditionExpression &expression, - std::size_t depth, - std::size_t *node_count, - int *columns, - int *rows, +bool addUniqueId( + const std::string &id, + const std::string &description, + std::unordered_set *ids, std::string *error) { - if (depth > ProjectLimits::kMaximumExpressionDepth) - { - setError(error, "条件表达式最多嵌套 12 层"); - return false; - } - ++*node_count; - if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) - { - setError(error, "单个网络最多包含 1024 个条件表达式节点和叶子"); - return false; - } - if (expression.id.empty()) - { - setError(error, "条件表达式 ID 不能为空"); - return false; - } - if (expression.id.size() > ProjectLimits::kMaximumIdBytes) + if (!ids->insert(id).second) { - setError(error, "条件表达式 ID 不能超过 128 个 UTF-8 字节"); + setError(error, description + " ID 必须唯一:" + id); return false; } - if (expression.kind == ConditionExpressionKind::Node) - { - if (!expression.node.has_value() || expression.wire.has_value() - || expression.gap.has_value() - || !expression.children.empty() || !expression.node->isCondition()) - { - setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); - return false; - } - *columns = 1; - *rows = 1; - return expression.node->validate(error); - } - if (expression.kind == ConditionExpressionKind::Wire) - { - if (expression.node.has_value() || !expression.wire.has_value() - || expression.gap.has_value() - || !expression.children.empty()) - { - setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); - return false; - } - if (!expression.wire->validate(error)) - { - return false; - } - *columns = expression.wire->columnSpan; - *rows = 1; - return true; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - if (expression.node.has_value() || expression.wire.has_value() - || !expression.gap.has_value() || !expression.children.empty()) - { - setError(error, "断路叶节点必须包含断路配置且不能包含逻辑节点或子表达式"); - return false; - } - if (!expression.gap->validate(error)) - { - return false; - } - *columns = expression.gap->columnSpan; - *rows = 1; - return true; - } - if (expression.kind != ConditionExpressionKind::Series - && expression.kind != ConditionExpressionKind::Parallel) - { - setError(error, "条件表达式使用了不支持的类型"); - return false; - } - if (expression.node.has_value() || expression.wire.has_value() - || expression.gap.has_value() - || expression.children.size() < 2U) - { - setError(error, "串联和并联表达式至少需要两个子表达式"); - return false; - } - if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) - { - setError(error, "单个串联或并联容器最多包含 64 个子表达式"); - return false; - } - - int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; - int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; - int parallel_columns = -1; - for (const ConditionExpression &child : expression.children) - { - if (child.kind == expression.kind) - { - setError(error, "同类型嵌套表达式必须先完成归一化"); - return false; - } - int child_columns = 0; - int child_rows = 0; - if (!validateConditionExpression( - child, depth + 1U, node_count, - &child_columns, &child_rows, error)) - { - return false; - } - if (expression.kind == ConditionExpressionKind::Series) - { - if (total_columns - > ProjectLimits::kMaximumConditionColumns - child_columns) - { - setError(error, "单个网络的条件区最多为 10 列,第 11 列固定用于输出指令"); - return false; - } - total_columns += child_columns; - total_rows = std::max(total_rows, child_rows); - } - else - { - if (parallel_columns >= 0 && child_columns != parallel_columns) - { - setError(error, "并联各支路必须使用显式横线或断路补齐到相同列宽"); - return false; - } - parallel_columns = child_columns; - total_columns = std::max(total_columns, child_columns); - if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) - { - setError(error, "单个网络的逻辑总行数最多为 64 行"); - return false; - } - total_rows += child_rows; - } - } - if (total_columns > ProjectLimits::kMaximumConditionColumns - || total_rows > ProjectLimits::kMaximumLogicRows) - { - setError(error, "单个网络最多使用 10 列条件、1 列输出和 64 行逻辑网格"); - return false; - } - *columns = total_columns; - *rows = total_rows; return true; } -int conditionColumnCount(const ConditionExpression &expression) -{ - if (expression.kind == ConditionExpressionKind::Node) - { - return 1; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - return expression.wire->columnSpan; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return expression.gap->columnSpan; - } - int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; - for (const ConditionExpression &child : expression.children) - { - const int child_columns = conditionColumnCount(child); - columns = expression.kind == ConditionExpressionKind::Series - ? columns + child_columns : std::max(columns, child_columns); - } - return columns; -} - } // namespace bool WordOperand::validate(std::string *error) const @@ -400,12 +267,7 @@ bool WordOperand::validate(std::string *error) const setError(error, "字操作数使用了不支持的类型"); return false; } - if (!address.isValid() || address.area() != RegisterArea::D) - { - setError(error, "字操作数必须使用有效的 D 区地址"); - return false; - } - return true; + return validateWordAddress(address, "字操作数", error); } std::optional registerAddressForLogicNode( @@ -422,12 +284,10 @@ std::optional registerAddressForLogicNode( { return value.address; } - else if constexpr (std::is_same_v - || std::is_same_v) + else { return value.destination; } - return std::nullopt; }, config); } @@ -488,7 +348,7 @@ bool LogicNode::validate(std::string *error) const return false; } return std::visit( - [error](const auto &config) { return validateConfig(config, error); }, + [error](const auto &value) { return validateConfig(value, error); }, config); } @@ -497,7 +357,6 @@ bool LogicNode::isConfigured() const return configured; } -// 判断当前节点是不是条件触点类节点 bool LogicNode::isCondition() const { return std::holds_alternative(config) @@ -505,7 +364,6 @@ bool LogicNode::isCondition() const || std::holds_alternative(config); } -// 判断当前节点是不是输出执行类节点 bool LogicNode::isOutput() const { return std::holds_alternative(config) @@ -513,210 +371,66 @@ bool LogicNode::isOutput() const || std::holds_alternative(config); } -ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) +bool LadderCell::validate(std::string *error) const { - ConditionExpression expression; - expression.id = logic_node.id; - expression.kind = ConditionExpressionKind::Node; - expression.node = std::move(logic_node); - return expression; -} - -bool WireSegment::validate(std::string *error) const -{ - if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) - { - setError(error, "横线跨度必须在 1~10 列范围内"); - return false; - } - return true; -} - -bool GapSegment::validate(std::string *error) const -{ - if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) + if (id.empty()) { - setError(error, "断路跨度必须在 1~10 列范围内"); + setError(error, "梯形图网格 ID 不能为空"); return false; } - return true; -} - -ConditionExpression ConditionExpression::fromWire( - std::string expression_id, int column_span) -{ - ConditionExpression expression; - expression.id = std::move(expression_id); - expression.kind = ConditionExpressionKind::Wire; - expression.wire = WireSegment{column_span}; - return expression; -} - -ConditionExpression ConditionExpression::fromGap( - std::string expression_id, int column_span) -{ - ConditionExpression expression; - expression.id = std::move(expression_id); - expression.kind = ConditionExpressionKind::Gap; - expression.gap = GapSegment{column_span}; - return expression; -} - -bool ConditionExpression::validate(std::string *error) const -{ - std::size_t node_count = 0U; - int columns = 0; - int rows = 0; - return validateConditionExpression( - *this, 1U, &node_count, &columns, &rows, error); -} - -bool ConditionExpression::validateForRunning(std::string *error) const -{ - if (!validate(error)) + if (id.size() > ProjectLimits::kMaximumIdBytes) { + setError(error, "梯形图网格 ID 不能超过 128 个 UTF-8 字节"); return false; } - if (kind == ConditionExpressionKind::Node) + if (kind == LadderCellKind::Node) { - if (!node->isConfigured()) + if (!node.has_value() || !node->isCondition()) { - setError(error, "梯形图条件节点 " + node->id + " 尚未配置"); + setError(error, "条件网格必须包含有效的条件节点"); return false; } - return true; - } - if (kind == ConditionExpressionKind::Wire) - { - return true; + return node->validate(error); } - if (kind == ConditionExpressionKind::Gap) + if (kind != LadderCellKind::Gap && kind != LadderCellKind::Wire) { - setError(error, "梯形图条件区存在未连接的空白网格"); + setError(error, "梯形图网格使用了不支持的类型"); return false; } - for (const ConditionExpression &child : children) + if (node.has_value()) { - if (!child.validateForRunning(error)) - { - return false; - } + setError(error, "空白或横线网格不能包含逻辑节点"); + return false; } return true; } -void normalizeConditionExpression(std::optional *expression) +bool VerticalConnection::validate(std::string *error) const { - // 删除节点后折叠单子项容器,并合并相邻同类容器,保持 JSON 结构稳定 - if (expression == nullptr || !expression->has_value()) + if (id.empty() || upperRungId.empty() || lowerRungId.empty()) { - return; - } - normalizeExpression(&expression->value()); - if (expression->value().kind != ConditionExpressionKind::Node - && expression->value().kind != ConditionExpressionKind::Wire - && expression->value().kind != ConditionExpressionKind::Gap - && expression->value().children.empty()) - { - expression->reset(); - } -} - -const LogicNode *findConditionNode( - const ConditionExpression &expression, const std::string &node_id) -{ - if (expression.kind == ConditionExpressionKind::Wire) - { - return nullptr; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return nullptr; - } - if (expression.kind == ConditionExpressionKind::Node) - { - return expression.node->id == node_id ? &*expression.node : nullptr; - } - for (const ConditionExpression &child : expression.children) - { - if (const LogicNode *node = findConditionNode(child, node_id)) - { - return node; - } - } - return nullptr; -} - -LogicNode *findConditionNode( - ConditionExpression &expression, const std::string &node_id) -{ - return const_cast(findConditionNode( - static_cast(expression), node_id)); -} - -const ConditionExpression *findConditionExpression( - const ConditionExpression &expression, const std::string &expression_id) -{ - if (expression.id == expression_id) - { - return &expression; - } - for (const ConditionExpression &child : expression.children) - { - if (const ConditionExpression *found = findConditionExpression( - child, expression_id)) - { - return found; - } - } - return nullptr; -} - -ConditionExpression *findConditionExpression( - ConditionExpression &expression, const std::string &expression_id) -{ - return const_cast(findConditionExpression( - static_cast(expression), expression_id)); -} - -void collectConditionNodes( - const ConditionExpression &expression, std::vector *nodes) -{ - if (nodes == nullptr) - { - return; - } - if (expression.kind == ConditionExpressionKind::Node) - { - nodes->push_back(&*expression.node); - return; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - return; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return; + setError(error, "竖线 ID 和上下行 ID 不能为空"); + return false; } - for (const ConditionExpression &child : expression.children) + if (id.size() > ProjectLimits::kMaximumIdBytes + || upperRungId.size() > ProjectLimits::kMaximumIdBytes + || lowerRungId.size() > ProjectLimits::kMaximumIdBytes) { - collectConditionNodes(child, nodes); + setError(error, "竖线及关联行 ID 不能超过 128 个 UTF-8 字节"); + return false; } -} - -void collectConditionExpressionIds( - const ConditionExpression &expression, std::vector *ids) -{ - if (ids == nullptr) + if (upperRungId == lowerRungId) { - return; + setError(error, "竖线必须连接两个相邻的不同视觉行"); + return false; } - ids->push_back(expression.id); - for (const ConditionExpression &child : expression.children) + if (columnBoundary < 0 + || columnBoundary > ProjectLimits::kMaximumConditionColumns) { - collectConditionExpressionIds(child, ids); + setError(error, "竖线列边界必须在 0~10 范围内"); + return false; } + return true; } bool LadderRung::validate(std::string *error) const @@ -724,95 +438,94 @@ bool LadderRung::validate(std::string *error) const return validateStructure(error); } -bool LadderRung::validateForRunning(std::string *error) const +bool LadderRung::validateStructure(std::string *error) const { - if (!validateStructure(error)) + if (id.empty() || isBlank(name)) { + setError(error, "梯形图行 ID 和名称不能为空"); return false; } - if (!condition.has_value() && !output.has_value()) + if (id.size() > ProjectLimits::kMaximumIdBytes + || name.size() > ProjectLimits::kMaximumTextBytes) { - return true; - } - if (!output.has_value()) - { - setError(error, "梯形图网络必须包含输出指令"); + setError(error, "梯形图行 ID 或名称过长"); return false; } - if (condition.has_value() && !condition->validateForRunning(error)) + if (containsLineBreak(comment) + || comment.size() > ProjectLimits::kMaximumRungCommentBytes) { + setError(error, "梯形图行注释必须是最多 128 个 UTF-8 字节的单行文本"); return false; } - if (!output->isConfigured()) + if (cells.empty()) { - setError(error, "输出节点 " + output->id + " 尚未配置"); - return false; - } - return true; -} - -bool LadderRung::validateStructure(std::string *error) const -{ - if (id.empty() || name.empty()) - { - setError(error, "梯形图网络 ID 和名称不能为空"); - return false; - } - if (id.size() > ProjectLimits::kMaximumIdBytes) - { - setError(error, "梯形图网络 ID 不能超过 128 个 UTF-8 字节"); - return false; - } - if (name.size() > ProjectLimits::kMaximumTextBytes) - { - setError(error, "梯形图网络名称不能超过 256 个 UTF-8 字节"); - return false; + if (output.has_value()) + { + setError(error, "输出指令必须有完整的十列条件路径"); + return false; + } + return true; } - if (containsLineBreak(comment)) + if (cells.size() + != static_cast(ProjectLimits::kMaximumConditionColumns)) { - setError(error, "梯形图网络注释只能使用单行文本"); + setError(error, "每条梯形图行必须严格包含 10 个条件网格"); return false; } - if (comment.size() > ProjectLimits::kMaximumRungCommentBytes) + + std::unordered_set cell_ids; + std::unordered_set node_ids; + for (const LadderCell &cell : cells) { - setError(error, "梯形图网络注释不能超过 128 个 UTF-8 字节"); - return false; + if (!cell.validate(error) + || !addUniqueId(cell.id, "同一行内的网格", &cell_ids, error)) + { + return false; + } + if (cell.node.has_value() + && !addUniqueId(cell.node->id, "同一行内的节点", &node_ids, error)) + { + return false; + } } - std::vector node_ids; - if (condition.has_value()) + if (output.has_value()) { - if (!condition->validate(error) || !validateUniqueExpressionIds(*condition, error)) + if (!output->validate(error) || !output->isOutput()) { + setError(error, "梯形图输出槽必须包含有效的输出指令"); return false; } - std::vector nodes; - collectConditionNodes(*condition, &nodes); - for (const LogicNode *node : nodes) + if (!addUniqueId(output->id, "同一行内的节点", &node_ids, error)) { - node_ids.push_back(node->id); + return false; } } - if (output.has_value() - && (!condition.has_value() - || conditionColumnCount(*condition) - != ProjectLimits::kMaximumConditionColumns)) + return true; +} + +bool LadderRung::validateForRunning(std::string *error) const +{ + if (!validateStructure(error)) { - setError(error, "带输出的梯形图网络必须用条件、横线或断路显式占满前 10 列"); return false; } - if (output.has_value()) + if (cells.size() + != static_cast(ProjectLimits::kMaximumConditionColumns)) { - if (!output->validate(error) || !output->isOutput()) + setError(error, "运行中的梯形图行必须严格包含 10 个条件网格"); + return false; + } + for (const LadderCell &cell : cells) + { + if (cell.node.has_value() && !cell.node->isConfigured()) { - setError(error, "梯形图网络输出必须是有效的输出指令"); + setError(error, "梯形图条件节点 " + cell.node->id + " 尚未配置"); return false; } - node_ids.push_back(output->id); } - std::sort(node_ids.begin(), node_ids.end()); - if (std::adjacent_find(node_ids.cbegin(), node_ids.cend()) != node_ids.cend()) + if (output.has_value() && !output->isConfigured()) { - setError(error, "同一网络内的逻辑节点 ID 必须唯一"); + setError(error, "输出节点 " + output->id + " 尚未配置"); return false; } return true; @@ -826,18 +539,7 @@ bool ControlLogic::validate(std::string *error) const bool ControlLogic::validate( const ProjectLimitSettings &limits, std::string *error) const { - if (!validateStructure(limits, error)) - { - return false; - } - for (const LadderRung &rung : rungs) - { - if (!rung.validate(error)) - { - return false; - } - } - return true; + return validateStructure(limits, error); } bool ControlLogic::validateStructure(std::string *error) const @@ -848,70 +550,101 @@ bool ControlLogic::validateStructure(std::string *error) const bool ControlLogic::validateStructure( const ProjectLimitSettings &limits, std::string *error) const { - if (id.empty() || name.empty()) + if (id.empty() || isBlank(name)) { setError(error, "控制逻辑 ID 和名称不能为空"); return false; } - if (id.size() > ProjectLimits::kMaximumIdBytes) + if (id.size() > ProjectLimits::kMaximumIdBytes + || name.size() > ProjectLimits::kMaximumTextBytes) { - setError(error, "控制逻辑 ID 不能超过 128 个 UTF-8 字节"); - return false; - } - if (name.size() > ProjectLimits::kMaximumTextBytes) - { - setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节"); + setError(error, "控制逻辑 ID 或名称过长"); return false; } if (rungs.size() > limits.maximumRungsPerLogic) { setError( error, - "控制逻辑“" + name + "”的网络数量为 " - + std::to_string(rungs.size()) + ",当前配置上限为 " + "控制逻辑行数为 " + std::to_string(rungs.size()) + + ",当前配置上限为 " + std::to_string(limits.maximumRungsPerLogic)); return false; } - if (hasDuplicateId(rungs)) + if (verticalConnections.size() + > ProjectLimits::kMaximumVerticalConnectionsPerLogic) { - setError(error, "同一控制逻辑内的网络 ID 必须唯一"); + setError(error, "控制逻辑中的竖线数量超过结构上限"); return false; } - std::vector node_ids; - std::vector expression_ids; + + std::unordered_set rung_ids; + std::unordered_set cell_ids; + std::unordered_set node_ids; for (const LadderRung &rung : rungs) { - if (!rung.validateStructure(error)) + if (!rung.validateStructure(error) + || !addUniqueId(rung.id, "同一控制逻辑内的行", &rung_ids, error)) { return false; } - if (rung.condition.has_value()) + for (const LadderCell &cell : rung.cells) { - std::vector nodes; - collectConditionNodes(*rung.condition, &nodes); - for (const LogicNode *node : nodes) + if (!addUniqueId(cell.id, "同一控制逻辑内的网格", &cell_ids, error)) + { + return false; + } + if (cell.node.has_value() + && !addUniqueId( + cell.node->id, "同一控制逻辑内的节点", &node_ids, error)) { - node_ids.push_back(node->id); + return false; } - collectConditionExpressionIds(*rung.condition, &expression_ids); } - if (rung.output.has_value()) + if (rung.output.has_value() + && !addUniqueId( + rung.output->id, "同一控制逻辑内的节点", &node_ids, error)) { - node_ids.push_back(rung.output->id); + return false; } } - std::sort(node_ids.begin(), node_ids.end()); - std::sort(expression_ids.begin(), expression_ids.end()); - if (std::adjacent_find(node_ids.cbegin(), node_ids.cend()) != node_ids.cend()) - { - setError(error, "同一控制逻辑内的逻辑节点 ID 必须唯一"); - return false; - } - if (std::adjacent_find(expression_ids.cbegin(), expression_ids.cend()) - != expression_ids.cend()) + + std::unordered_set connection_ids; + std::unordered_set connection_positions; + for (const VerticalConnection &connection : verticalConnections) { - setError(error, "同一控制逻辑内的条件表达式 ID 必须唯一"); - return false; + if (!connection.validate(error) + || !addUniqueId( + connection.id, "同一控制逻辑内的竖线", + &connection_ids, error)) + { + return false; + } + const auto upper = std::find_if( + rungs.cbegin(), rungs.cend(), + [&connection](const LadderRung &rung) + { + return rung.id == connection.upperRungId; + }); + const auto lower = std::find_if( + rungs.cbegin(), rungs.cend(), + [&connection](const LadderRung &rung) + { + return rung.id == connection.lowerRungId; + }); + if (upper == rungs.cend() || lower == rungs.cend() + || lower != upper + 1) + { + setError(error, "竖线只能连接当前顺序中相邻的上下两行"); + return false; + } + const std::string position = connection.upperRungId + "\n" + + connection.lowerRungId + "\n" + + std::to_string(connection.columnBoundary); + if (!connection_positions.insert(position).second) + { + setError(error, "同一行间列边界不能保存重复竖线"); + return false; + } } return true; } @@ -935,5 +668,72 @@ bool ControlLogic::validateForRunning( return false; } } - return true; + return validateOutputConnectivity(*this, error); +} + +const LadderCell *findLadderCell( + const LadderRung &rung, const std::string &cell_id) +{ + const auto found = std::find_if( + rung.cells.cbegin(), rung.cells.cend(), + [&cell_id](const LadderCell &cell) { return cell.id == cell_id; }); + return found == rung.cells.cend() ? nullptr : &*found; +} + +LadderCell *findLadderCell(LadderRung &rung, const std::string &cell_id) +{ + return const_cast(findLadderCell( + static_cast(rung), cell_id)); +} + +const VerticalConnection *findVerticalConnection( + const ControlLogic &logic, const std::string &connection_id) +{ + const auto found = std::find_if( + logic.verticalConnections.cbegin(), logic.verticalConnections.cend(), + [&connection_id](const VerticalConnection &connection) + { + return connection.id == connection_id; + }); + return found == logic.verticalConnections.cend() ? nullptr : &*found; +} + +VerticalConnection *findVerticalConnection( + ControlLogic &logic, const std::string &connection_id) +{ + return const_cast(findVerticalConnection( + static_cast(logic), connection_id)); +} + +void collectConditionNodes( + const LadderRung &rung, std::vector *nodes) +{ + if (nodes == nullptr) + { + return; + } + for (const LadderCell &cell : rung.cells) + { + if (cell.node.has_value()) + { + nodes->push_back(&*cell.node); + } + } +} + +void collectLogicNodes( + const ControlLogic &logic, std::vector *nodes) +{ + if (nodes == nullptr) + { + return; + } + for (const LadderRung &rung : logic.rungs) + { + collectConditionNodes(rung, nodes); + if (rung.output.has_value()) + { + nodes->push_back(&*rung.output); + } + } } diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index be4e5e8..2525576 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -9,253 +9,172 @@ #include #include -// 普通触点的导通方式;常闭触点会对读取到的位值取反 enum class ContactMode { - NormallyOpen, // 常开:位值为 1 时触点导通 - NormallyClosed // 常闭:位值为 0 时触点导通 + NormallyOpen, + NormallyClosed }; -// 线圈写入方式:普通写入、置位保持、复位清零 enum class CoilMode { - Normal, // 普通写入:直接写入当前逻辑结果 - Set, // 置位:逻辑结果为 1 时保持为 1 - Reset // 复位:逻辑结果为 1 时清零 + Normal, + Set, + Reset }; -// 上升沿/下降沿触点的边沿方向 enum class EdgeMode { - Rising, // 上升沿:信号从 0 变成 1 - Falling // 下降沿:信号从 1 变成 0 + Rising, + Falling }; -// D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 enum class ComparisonOperator { - Equal, // 等于 - NotEqual, // 不等于 - LessThan, // 小于 - LessThanOrEqual, // 小于或等于 - GreaterThan, // 大于 - GreaterThanOrEqual // 大于或等于 + Equal, + NotEqual, + LessThan, + LessThanOrEqual, + GreaterThan, + GreaterThanOrEqual }; -// 字操作数可以来自常量,也可以来自 D 寄存器 enum class WordOperandKind { - Constant, // 使用固定数值 - Register // 使用 D 寄存器中的数值 + Constant, + Register }; -// MOVE、ADD/SUB 共用的字操作数 struct WordOperand { - WordOperandKind kind = WordOperandKind::Constant; // 操作数来源 - RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址 - std::int16_t constant = 0; // kind 为 Constant 时使用的数值 + WordOperandKind kind = WordOperandKind::Constant; + RegisterAddress address{RegisterArea::D, 0}; + std::int16_t constant = 0; - // 常量始终有效;寄存器操作数必须是有效 D 地址 bool validate(std::string *error = nullptr) const; }; -// 普通 M 寄存器触点,地址 + 常开常闭 struct ContactNodeConfig { - RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 - ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭 + RegisterAddress address{RegisterArea::M, 0}; + ContactMode mode = ContactMode::NormallyOpen; }; -// 边沿触点,地址 + 上升 / 下降沿 struct EdgeContactNodeConfig { - // 用于检测 M 寄存器的上升沿或下降沿 RegisterAddress address{RegisterArea::M, 0}; EdgeMode mode = EdgeMode::Rising; }; -// 线圈输出配置,决定如何写入 M 寄存器 struct CoilNodeConfig { - // 线圈要写入的 M 寄存器地址 RegisterAddress address{RegisterArea::M, 0}; CoilMode mode = CoilMode::Normal; }; -// D 寄存器比较配置 struct CompareNodeConfig { - // 要参与比较的 D 寄存器地址 RegisterAddress address{RegisterArea::D, 0}; - // 比较运算符 ComparisonOperator comparison = ComparisonOperator::Equal; - // 与寄存器值比较的常量 std::int16_t value = 0; }; -// MOVE 输出:把一个字操作数写入目标 D 地址 struct MoveNodeConfig { - WordOperand source; // 要写入的源操作数 - RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 + WordOperand source; + RegisterAddress destination{RegisterArea::D, 0}; }; enum class ArithmeticOperation { - Add, // 加法 - Subtract // 减法 + Add, + Subtract }; -// ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 struct ArithmeticNodeConfig { - ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法 - WordOperand left; // 左操作数 - WordOperand right; // 右操作数 - RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址 + ArithmeticOperation operation = ArithmeticOperation::Add; + WordOperand left; + WordOperand right; + RegisterAddress destination{RegisterArea::D, 0}; }; -// 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令 using LogicNodeConfig = std::variant< - ContactNodeConfig, // 普通常开或常闭触点 - EdgeContactNodeConfig, // 上升沿或下降沿触点 - CoilNodeConfig, // 普通、置位或复位线圈 - CompareNodeConfig, // D 寄存器比较节点 - MoveNodeConfig, // MOVE 数据传送输出 - ArithmeticNodeConfig>; // ADD 或 SUB 算术输出 + ContactNodeConfig, + EdgeContactNodeConfig, + CoilNodeConfig, + CompareNodeConfig, + MoveNodeConfig, + ArithmeticNodeConfig>; -// 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数 std::optional registerAddressForLogicNode( const LogicNodeConfig &config); -// 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建 void collectRegisterAddressesForLogicNode( const LogicNodeConfig &config, std::vector *addresses); -// 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿 struct LogicNode { - std::string id; // 节点唯一 ID - LogicNodeConfig config; // 节点的具体指令配置 - bool configured = true; // 是否已完成配置 + std::string id; + LogicNodeConfig config; + bool configured = true; - // 校验节点 ID、配置类型和配置内容 bool validate(std::string *error = nullptr) const; bool isConfigured() const; - // 判断节点能否放在条件区或固定输出槽 bool isCondition() const; bool isOutput() const; }; -enum class ConditionExpressionKind -{ - Node, // 一个实际的条件节点 - Wire, // 一段横线 - Gap, // 一段明确断开的空白网格 - Series, // 多个条件串联,必须全部满足 - Parallel // 多个条件并联,满足任意一条即可 -}; - -// 条件区中的持久化横线;columnSpan 表示跨越的网格列数 -struct WireSegment +enum class LadderCellKind { - static constexpr int kMinimumColumnSpan = 1; - static constexpr int kMaximumColumnSpan = - ProjectLimits::kMaximumConditionColumns; - - int columnSpan = 1; // 横线占用的网格列数 - - bool validate(std::string *error = nullptr) const; + Gap, + Wire, + Node }; -// 条件区中的持久化断路;columnSpan 表示连续空白网格列数 -struct GapSegment +// 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位 +struct LadderCell { - static constexpr int kMinimumColumnSpan = 1; - static constexpr int kMaximumColumnSpan = - ProjectLimits::kMaximumConditionColumns; - - int columnSpan = 1; // 断路占用的网格列数 + std::string id; + LadderCellKind kind = LadderCellKind::Gap; + std::optional node; bool validate(std::string *error = nullptr) const; }; -// 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 -struct ConditionExpression +// 两个相邻视觉行之间、指定列边界上的一段竖线 +struct VerticalConnection { - std::string id; // 表达式唯一 ID - ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 - std::optional node; // kind 为 Node 时保存的节点 - std::optional wire; // kind 为 Wire 时保存的横线 - std::optional gap; // kind 为 Gap 时保存的断路 - std::vector children; // 串联或并联的子表达式 + std::string id; + std::string upperRungId; + std::string lowerRungId; + int columnBoundary = 0; - // 把一个逻辑节点包装成条件表达式 - static ConditionExpression fromNode(LogicNode node); - // 创建一段指定列数的横线表达式 - static ConditionExpression fromWire(std::string id, int column_span = 1); - // 创建一段指定列数的断路表达式 - static ConditionExpression fromGap(std::string id, int column_span = 1); - // 编辑态校验允许空配置节点,但必须保持树结构合法 bool validate(std::string *error = nullptr) const; - // 运行态校验额外要求每个节点都已经配置完成 - bool validateForRunning(std::string *error = nullptr) const; }; -// 删除节点后整理表达式结构 -void normalizeConditionExpression(std::optional *expression); - -// 按节点 ID 查找条件节点,只读版本 -const LogicNode *findConditionNode( - const ConditionExpression &expression, const std::string &node_id); - -// 按节点 ID 查找条件节点,可修改版本 -LogicNode *findConditionNode( - ConditionExpression &expression, const std::string &node_id); - -// 按表达式 ID 查找子表达式,只读版本 -const ConditionExpression *findConditionExpression( - const ConditionExpression &expression, const std::string &expression_id); - -// 按表达式 ID 查找子表达式,可修改版本 -ConditionExpression *findConditionExpression( - ConditionExpression &expression, const std::string &expression_id); - -// 收集表达式中的所有条件节点 -void collectConditionNodes( - const ConditionExpression &expression, std::vector *nodes); - -// 收集表达式和子表达式的所有 ID -void collectConditionExpressionIds( - const ConditionExpression &expression, std::vector *ids); - -// 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满 +// 连续梯形图的一条视觉行,不再是隔离的网络容器 struct LadderRung { - std::string id; // 网络唯一 ID - std::string name; // 网络名称 - std::string comment; // 网络注释 - std::optional condition; // 条件区表达式;空值只用于空网络草稿 - std::optional output; // 网络右侧的输出指令 + std::string id; + std::string name; + std::string comment; + std::optional output; + std::vector cells; - // 编辑态允许没有条件或输出,便于逐步搭建网络 bool validate(std::string *error = nullptr) const; - // 只检查表达式拓扑、列数和输出位置 bool validateStructure(std::string *error = nullptr) const; - // 运行态要求有输出且所有引用都已配置 bool validateForRunning(std::string *error = nullptr) const; }; -// 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它 +// 一张连续梯形图,网络由横竖连接关系自然形成 struct ControlLogic { - std::string id; // 控制逻辑唯一 ID - std::string name; // 控制逻辑名称 - std::vector rungs; // 很多条 LadderRung(梯形图网络/梯级) - bool enabled = true; // 是否参与运行扫描 + std::string id; + std::string name; + std::vector rungs; + bool enabled = true; + std::vector verticalConnections; - // 校验可保存结构 bool validate(std::string *error = nullptr) const; bool validate( const ProjectLimitSettings &limits, @@ -264,9 +183,21 @@ struct ControlLogic bool validateStructure( const ProjectLimitSettings &limits, std::string *error = nullptr) const; - // 校验运行所需资源和网络配置 bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning( const ProjectLimitSettings &limits, std::string *error = nullptr) const; }; + +const LadderCell *findLadderCell( + const LadderRung &rung, const std::string &cell_id); +LadderCell *findLadderCell( + LadderRung &rung, const std::string &cell_id); +const VerticalConnection *findVerticalConnection( + const ControlLogic &logic, const std::string &connection_id); +VerticalConnection *findVerticalConnection( + ControlLogic &logic, const std::string &connection_id); +void collectConditionNodes( + const LadderRung &rung, std::vector *nodes); +void collectLogicNodes( + const ControlLogic &logic, std::vector *nodes); diff --git a/app/src/domain/project_limits.h b/app/src/domain/project_limits.h index 3bfd323..bc27ae2 100644 --- a/app/src/domain/project_limits.h +++ b/app/src/domain/project_limits.h @@ -2,7 +2,8 @@ #include -// 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 +// 程序允许的绝对硬上限统一放在这里,避免 UI、服务和文件读写各自使用不同数字 +// application.ini 只能收紧其中支持配置的上限,不能突破这些硬上限 namespace ProjectLimits { constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存 @@ -13,16 +14,14 @@ constexpr std::size_t kMaximumHmiControlsPerProject = 2048U; // 一个工程最 constexpr std::size_t kMaximumAlarmDefinitions = 256U; // 一个工程最多配置 256 条报警 constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释 constexpr std::size_t kMaximumControlLogics = 32U; // 一个工程最多放 32 组梯形图控制逻辑 -constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 个网络 -constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 个网络 - -constexpr std::size_t kMaximumExpressionNodesPerRung = 1024U; // 一个网络里的触点、横线和内部结构最多共 1024 个 -constexpr std::size_t kMaximumExpressionDepth = 12U; // 并联套并联时最多套 12 层,防止结构无限变复杂 -constexpr std::size_t kMaximumExpressionChildren = 64U; // 一个串联组或并联组最多放 64 个子项 -constexpr int kMaximumConditionColumns = 10; // 一个网络前面的条件区最多 10 列 -constexpr int kMaximumLadderColumns = 11; // 一个网络总共 11 列,最后 1 列专门放输出 -constexpr int kMaximumLogicRows = 64; // 一个网络上下最多 64 行并联支路 - +constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 行 +constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 行 + +constexpr int kMaximumConditionColumns = 10; // 每行固定 10 个条件网格 +constexpr int kMaximumLadderColumns = 11; // 第 11 列固定用于输出 +constexpr std::size_t kMaximumVerticalConnectionsPerLogic = + (kMaximumRungsPerLogic - 1U) + * static_cast(kMaximumConditionColumns + 1); // 一组控制逻辑最多保存的竖线总数 static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 @@ -65,34 +64,36 @@ constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一 constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱 constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 -} +} // namespace ProjectLimits -// 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限 +// 本次程序实际使用的可配置上限;默认采用代码硬上限,INI 只能将它们调小 struct ProjectLimitSettings { - std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; + std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; // 一个工程允许的 HMI 页面数 std::size_t maximumHmiControlsPerPage = - ProjectLimits::kMaximumHmiControlsPerPage; + ProjectLimits::kMaximumHmiControlsPerPage; // 每个 HMI 页面允许的控件数 std::size_t maximumAlarmDefinitions = - ProjectLimits::kMaximumAlarmDefinitions; - std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; - std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; - int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; + ProjectLimits::kMaximumAlarmDefinitions; // 一个工程允许的报警数 + std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; // 一个工程允许的控制逻辑组数 + std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; // 每组控制逻辑允许的梯形图行数 + int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; // 输出面板最多保留的消息数 }; -// 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制 +// 新建 HMI 页面使用的默认尺寸;不会修改已有页面,也不能超出上面的宽高硬限制 struct HmiDefaultSettings { - int pageWidth = ProjectLimits::kDefaultHmiPageWidth; - int pageHeight = ProjectLimits::kDefaultHmiPageHeight; + int pageWidth = ProjectLimits::kDefaultHmiPageWidth; // 新建页面的默认宽度 + int pageHeight = ProjectLimits::kDefaultHmiPageHeight; // 新建页面的默认高度 }; +// 返回长期有效的默认上限,供没有传入 INI 配置的代码使用 inline const ProjectLimitSettings &defaultProjectLimitSettings() { static const ProjectLimitSettings settings; return settings; } +// 返回长期有效的默认页面尺寸,供没有传入 INI 配置的代码使用 inline const HmiDefaultSettings &defaultHmiSettings() { static const HmiDefaultSettings settings; diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 3651df9..1f86fad 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -401,10 +401,7 @@ bool Project::validate( for (const LadderRung &rung : logic.rungs) { std::vector nodes; - if (rung.condition.has_value()) - { - collectConditionNodes(*rung.condition, &nodes); - } + collectConditionNodes(rung, &nodes); if (rung.output.has_value()) { nodes.push_back(&*rung.output); diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 0facb3c..2c12d74 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -26,7 +26,7 @@ struct ProjectMetadata // 工程显示名称 std::string name; // 工程文件格式版本 - std::string formatVersion = "1.0"; + std::string formatVersion = "2.0"; }; // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 diff --git a/app/src/domain/runtime_state.cpp b/app/src/domain/runtime_state.cpp index 429eae4..6083131 100644 --- a/app/src/domain/runtime_state.cpp +++ b/app/src/domain/runtime_state.cpp @@ -15,41 +15,41 @@ ModeTransitionResult RuntimeState::enterEditing() // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 if (mode_ == ApplicationMode::Editing) { - return {false, ModeTransitionError::AlreadyInRequestedMode}; + return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; } mode_ = ApplicationMode::Editing; - return {true, ModeTransitionError::None}; + return {true, ModeTransitionError::None, {}}; } ModeTransitionResult RuntimeState::enterOfflineRunning() { if (mode_ == ApplicationMode::OfflineRunning) { - return {false, ModeTransitionError::AlreadyInRequestedMode}; + return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; } if (mode_ != ApplicationMode::Editing) { - return {false, ModeTransitionError::MustReturnToEditing}; + return {false, ModeTransitionError::MustReturnToEditing, {}}; } mode_ = ApplicationMode::OfflineRunning; - return {true, ModeTransitionError::None}; + return {true, ModeTransitionError::None, {}}; } ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) { if (mode_ == ApplicationMode::OnlineRunning) { - return {false, ModeTransitionError::AlreadyInRequestedMode}; + return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; } if (mode_ != ApplicationMode::Editing) { - return {false, ModeTransitionError::MustReturnToEditing}; + return {false, ModeTransitionError::MustReturnToEditing, {}}; } if (!initial_plc_read_completed) { // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 - return {false, ModeTransitionError::InitialPlcReadRequired}; + return {false, ModeTransitionError::InitialPlcReadRequired, {}}; } mode_ = ApplicationMode::OnlineRunning; - return {true, ModeTransitionError::None}; + return {true, ModeTransitionError::None, {}}; } diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h index 16e34d0..33ecbdb 100644 --- a/app/src/domain/runtime_state.h +++ b/app/src/domain/runtime_state.h @@ -1,5 +1,7 @@ #pragma once +#include + // 应用当前运行模式;三个状态之间不能直接从离线切到真机 enum class ApplicationMode { @@ -86,6 +88,8 @@ struct ModeTransitionResult bool succeeded = false; // 切换失败原因 ModeTransitionError error = ModeTransitionError::None; + // 服务层补充的具体失败原因,状态机自身可以留空 + std::string detail; }; // 保存当前模式并执行最基本的状态机约束 diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 7b192bf..a364069 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -18,7 +18,7 @@ namespace { // 当前读写实现支持的工程文件格式版本 -constexpr const char *kCurrentFormatVersion = "1.0"; +constexpr const char *kCurrentFormatVersion = "2.0"; // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 struct ParseState @@ -1283,185 +1283,30 @@ bool parseLogicNode( return true; } -QString expressionKindText(ConditionExpressionKind kind) -{ - switch (kind) - { - case ConditionExpressionKind::Node: - return QStringLiteral("node"); - case ConditionExpressionKind::Wire: - return QStringLiteral("wire"); - case ConditionExpressionKind::Gap: - return QStringLiteral("gap"); - case ConditionExpressionKind::Series: - return QStringLiteral("series"); - case ConditionExpressionKind::Parallel: - return QStringLiteral("parallel"); - } - return {}; -} - -QJsonObject serializeConditionExpression(const ConditionExpression &expression) -{ - QJsonObject object; - object.insert(QStringLiteral("id"), fromUtf8(expression.id)); - object.insert(QStringLiteral("kind"), expressionKindText(expression.kind)); - if (expression.kind == ConditionExpressionKind::Node) - { - object.insert(QStringLiteral("node"), serializeLogicNode(*expression.node)); - } - else if (expression.kind == ConditionExpressionKind::Wire) - { - object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); - } - else if (expression.kind == ConditionExpressionKind::Gap) - { - object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); - } - else - { - QJsonArray children; - for (const ConditionExpression &child : expression.children) - { - children.append(serializeConditionExpression(child)); - } - object.insert(QStringLiteral("children"), children); - } - return object; -} - -bool parseConditionExpression( - const QJsonObject &object, - const std::string &context, - ConditionExpression *expression, - ParseState *state, - std::size_t depth, - std::size_t *node_count) -{ - if (depth > ProjectLimits::kMaximumExpressionDepth) - { - return state->fail( - ProjectStorageError::InvalidField, - context + " 的嵌套深度超过 12 层"); - } - ++*node_count; - if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) - { - return state->fail( - ProjectStorageError::InvalidField, - context + " 的表达式节点总数超过 1024 个"); - } - std::string kind; - if (!readString( - object, "id", context, &expression->id, state, - ProjectLimits::kMaximumIdBytes) - || !readString(object, "kind", context, &kind, state)) - { - return false; - } - if (kind == "node") - { - QJsonObject node; - if (!readObject(object, "node", context, &node, state)) - { - return false; - } - LogicNode parsed_node; - if (!parseLogicNode(node, context + ".node", &parsed_node, state)) - { - return false; - } - expression->kind = ConditionExpressionKind::Node; - expression->node = std::move(parsed_node); - return true; - } - if (kind == "wire") - { - int column_span = 0; - if (!readInt( - object, - "columnSpan", - context, - WireSegment::kMinimumColumnSpan, - WireSegment::kMaximumColumnSpan, - &column_span, - state)) - { - return false; - } - expression->kind = ConditionExpressionKind::Wire; - expression->wire = WireSegment{column_span}; - return true; - } - if (kind == "gap") - { - int column_span = 0; - if (!readInt( - object, - "columnSpan", - context, - GapSegment::kMinimumColumnSpan, - GapSegment::kMaximumColumnSpan, - &column_span, - state)) - { - return false; - } - expression->kind = ConditionExpressionKind::Gap; - expression->gap = GapSegment{column_span}; - return true; - } - if (kind != "series" && kind != "parallel") - { - return state->fail( - ProjectStorageError::InvalidField, - context + ".kind 必须是 node、wire、gap、series 或 parallel"); - } - QJsonArray children; - if (!readArray( - object, "children", context, &children, state, - static_cast(ProjectLimits::kMaximumExpressionChildren))) - { - return false; - } - expression->kind = kind == "series" - ? ConditionExpressionKind::Series : ConditionExpressionKind::Parallel; - expression->children.reserve(static_cast(children.size())); - for (int index = 0; index < children.size(); ++index) - { - if (!children.at(index).isObject()) - { - return state->fail( - ProjectStorageError::InvalidField, - context + ".children 的元素必须是对象"); - } - ConditionExpression child; - if (!parseConditionExpression( - children.at(index).toObject(), - context + ".children[" + std::to_string(index) + ']', - &child, - state, - depth + 1U, - node_count)) - { - return false; - } - expression->children.push_back(std::move(child)); - } - return true; -} - QJsonObject serializeLadderRung(const LadderRung &rung) { QJsonObject object; object.insert(QStringLiteral("id"), fromUtf8(rung.id)); object.insert(QStringLiteral("name"), fromUtf8(rung.name)); object.insert(QStringLiteral("comment"), fromUtf8(rung.comment)); - object.insert( - QStringLiteral("condition"), - rung.condition.has_value() - ? QJsonValue(serializeConditionExpression(*rung.condition)) - : QJsonValue(QJsonValue::Null)); + QJsonArray cells; + for (const LadderCell &cell : rung.cells) + { + QJsonObject cell_object; + cell_object.insert(QStringLiteral("id"), fromUtf8(cell.id)); + cell_object.insert( + QStringLiteral("kind"), + cell.kind == LadderCellKind::Node + ? QStringLiteral("node") + : cell.kind == LadderCellKind::Wire + ? QStringLiteral("wire") : QStringLiteral("gap")); + if (cell.node.has_value()) + { + cell_object.insert(QStringLiteral("node"), serializeLogicNode(*cell.node)); + } + cells.append(cell_object); + } + object.insert(QStringLiteral("cells"), cells); object.insert( QStringLiteral("output"), rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output)) @@ -1475,42 +1320,71 @@ bool parseLadderRung( LadderRung *rung, ParseState *state) { - QJsonValue condition; + QJsonArray cells; QJsonValue output; if (!readString( object, "id", context, &rung->id, state, ProjectLimits::kMaximumIdBytes) || !readString(object, "name", context, &rung->name, state) || !readString(object, "comment", context, &rung->comment, state) + || !readArray(object, "cells", context, &cells, state, + ProjectLimits::kMaximumConditionColumns) || !readValue(object, "output", context, &output, state)) { return false; } - if (!readValue(object, "condition", context, &condition, state)) - { - return false; - } - if (condition.isNull()) - { - rung->condition.reset(); - } - else if (!condition.isObject()) + if (cells.size() != ProjectLimits::kMaximumConditionColumns) { - return state->fail( - ProjectStorageError::InvalidField, - context + ".condition 必须是对象或 null"); + return state->fail(ProjectStorageError::InvalidField, + context + ".cells 必须严格包含 10 个网格"); } - else + for (int index = 0; index < cells.size(); ++index) { - ConditionExpression parsed_condition; - std::size_t expression_node_count = 0U; - if (!parseConditionExpression( - condition.toObject(), context + ".condition", &parsed_condition, - state, 1U, &expression_node_count)) + if (!cells.at(index).isObject()) + { + return state->fail(ProjectStorageError::InvalidField, + context + ".cells 的元素必须是对象"); + } + const QJsonObject cell_object = cells.at(index).toObject(); + const std::string cell_context = context + ".cells[" + + std::to_string(index) + ']'; + LadderCell cell; + std::string kind; + if (!readString(cell_object, "id", cell_context, &cell.id, state, + ProjectLimits::kMaximumIdBytes) + || !readString(cell_object, "kind", cell_context, &kind, state)) { return false; } - rung->condition = std::move(parsed_condition); + if (kind == "gap") + { + cell.kind = LadderCellKind::Gap; + } + else if (kind == "wire") + { + cell.kind = LadderCellKind::Wire; + } + else if (kind == "node") + { + cell.kind = LadderCellKind::Node; + QJsonObject node_object; + if (!readObject(cell_object, "node", cell_context, &node_object, state)) + { + return false; + } + LogicNode node; + if (!parseLogicNode(node_object, cell_context + ".node", &node, state)) + { + return false; + } + cell.node = std::move(node); + } + else + { + return state->fail(ProjectStorageError::InvalidField, + cell_context + ".kind 必须是 gap、wire 或 node"); + } + rung->cells.push_back(std::move(cell)); } if (output.isNull()) { @@ -1539,11 +1413,22 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) { rungs.append(serializeLadderRung(rung)); } + QJsonArray connections; + for (const VerticalConnection &connection : logic.verticalConnections) + { + QJsonObject item; + item.insert(QStringLiteral("id"), fromUtf8(connection.id)); + item.insert(QStringLiteral("upperRungId"), fromUtf8(connection.upperRungId)); + item.insert(QStringLiteral("lowerRungId"), fromUtf8(connection.lowerRungId)); + item.insert(QStringLiteral("columnBoundary"), connection.columnBoundary); + connections.append(item); + } QJsonObject object; object.insert(QStringLiteral("id"), fromUtf8(logic.id)); object.insert(QStringLiteral("name"), fromUtf8(logic.name)); object.insert(QStringLiteral("enabled"), logic.enabled); object.insert(QStringLiteral("rungs"), rungs); + object.insert(QStringLiteral("verticalConnections"), connections); return object; } @@ -1555,6 +1440,7 @@ bool parseControlLogic( ParseState *state) { QJsonArray rungs; + QJsonArray connections; if (!readString( object, "id", context, &logic->id, state, ProjectLimits::kMaximumIdBytes) @@ -1562,7 +1448,10 @@ bool parseControlLogic( || !readBool(object, "enabled", context, &logic->enabled, state) || !readArray( object, "rungs", context, &rungs, state, - static_cast(limits.maximumRungsPerLogic))) + static_cast(limits.maximumRungsPerLogic)) + || !readArray( + object, "verticalConnections", context, &connections, state, + static_cast(ProjectLimits::kMaximumVerticalConnectionsPerLogic))) { return false; } @@ -1586,6 +1475,35 @@ bool parseControlLogic( } logic->rungs.push_back(std::move(rung)); } + for (int index = 0; index < connections.size(); ++index) + { + if (!connections.at(index).isObject()) + { + return state->fail(ProjectStorageError::InvalidField, + context + ".verticalConnections 的元素必须是对象"); + } + const QJsonObject item = connections.at(index).toObject(); + VerticalConnection connection; + int boundary = 0; + const std::string item_context = context + ".verticalConnections[" + + std::to_string(index) + ']'; + if (!readString(item, "id", item_context, &connection.id, state, + ProjectLimits::kMaximumIdBytes) + || !readString(item, "upperRungId", item_context, + &connection.upperRungId, state, + ProjectLimits::kMaximumIdBytes) + || !readString(item, "lowerRungId", item_context, + &connection.lowerRungId, state, + ProjectLimits::kMaximumIdBytes) + || !readInt(item, "columnBoundary", item_context, 0, + ProjectLimits::kMaximumConditionColumns, + &boundary, state)) + { + return false; + } + connection.columnBoundary = boundary; + logic->verticalConnections.push_back(std::move(connection)); + } return true; } diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index 927d8f3..3707b70 100644 --- a/app/src/infrastructure/json_project_storage.h +++ b/app/src/infrastructure/json_project_storage.h @@ -2,7 +2,7 @@ #include "domain/project_storage.h" -// 严格读写当前 1.0 JSON 工程格式 +// 严格读写当前 2.0 JSON 工程格式 class JsonProjectStorage final : public ProjectStorage { public: diff --git a/app/src/main.cpp b/app/src/main.cpp index dfbf34d..5a6be1e 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -141,7 +141,10 @@ int main(int argc, char *argv[]) OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository); // 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期 RuntimeModeService runtime_mode_service( - project_service, offline_simulation_service, online_logic_monitor_service); + project_service, + logic_editor_service, + offline_simulation_service, + online_logic_monitor_service); // 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 runtime_mode_service.configurePlc( plc_communication_service, diff --git a/app/src/services/logic_command_service.cpp b/app/src/services/logic_command_service.cpp index d366296..30b1759 100644 --- a/app/src/services/logic_command_service.cpp +++ b/app/src/services/logic_command_service.cpp @@ -201,7 +201,7 @@ bool parseInt16( LogicCommandResult executionFailure( const std::string &message, LogicCommandOpcode opcode) { - return {false, message, {}, {}, opcode}; + return {false, message, {}, {}, opcode, {}, false}; } } // namespace @@ -425,6 +425,8 @@ LogicCommandResult LogicCommandService::execute( } const LogicCommandOpcode opcode = parsed.command.opcode; LogicEditorResult edited; + LogicEditCursor next_cursor; + bool has_next_cursor = false; if (request.target.kind == LogicCommandTargetKind::ExistingNode) { @@ -439,7 +441,7 @@ LogicCommandResult LogicCommandService::execute( if (!isLoad(opcode)) { return executionFailure( - "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); + "已有条件节点只能替换为触点或比较指令", opcode); } } else if (existing->isOutput()) @@ -461,12 +463,7 @@ LogicCommandResult LogicCommandService::execute( } else if (isCondition(opcode)) { - if (isLoad(opcode) && request.continuing) - { - edited = editor_service_.appendCondition( - request.logicId, {}, parsed.command.config, true); - } - else if (isOr(opcode)) + if (isOr(opcode)) { const std::string rung_id = request.continuing ? request.currentRungId : request.target.rungId; @@ -489,20 +486,19 @@ LogicCommandResult LogicCommandService::execute( parsed.command.config, true); } - } - else if (request.continuing) - { - const LadderRung *rung = editor_service_.findRung( - request.logicId, request.currentRungId); - if (rung != nullptr && rung->output.has_value()) + if (edited.succeeded) { - return executionFailure( - "当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络", - opcode); + const std::string edited_rung = editor_service_.rungIdForNode( + request.logicId, edited.id); + const int next_column = std::min( + request.target.column + 1, + ProjectLimits::kMaximumConditionColumns); + next_cursor = { + edited_rung.empty() ? rung_id : edited_rung, + next_column, + next_column == ProjectLimits::kMaximumConditionColumns}; + has_next_cursor = true; } - edited = editor_service_.appendCondition( - request.logicId, request.currentRungId, - parsed.command.config, true); } else { @@ -513,46 +509,35 @@ LogicCommandResult LogicCommandService::execute( } if ((opcode == LogicCommandOpcode::And || opcode == LogicCommandOpcode::AndInverse) - && (!request.target.rungId.empty() - && (editor_service_.findRung( - request.logicId, request.target.rungId) == nullptr - || !editor_service_.findRung( - request.logicId, request.target.rungId) - ->condition.has_value()))) + && !request.target.rungId.empty()) { - return executionFailure( - "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); - } - switch (request.target.kind) - { - case LogicCommandTargetKind::EmptyColumn: - edited = editor_service_.insertConditionAtColumn( - request.logicId, request.target.rungId, - request.target.column, parsed.command.config, true); - break; - case LogicCommandTargetKind::BranchEmptyColumn: - edited = editor_service_.insertConditionInBranchAtColumn( - request.logicId, request.target.rungId, - request.target.expressionId, request.target.column, - parsed.command.config, true); - break; - case LogicCommandTargetKind::WireColumn: - edited = editor_service_.replaceWireColumnWithCondition( - request.logicId, request.target.rungId, - request.target.expressionId, request.target.column, - parsed.command.config, true); - break; - case LogicCommandTargetKind::GapColumn: - edited = editor_service_.replaceGapColumnWithCondition( - request.logicId, request.target.rungId, - request.target.expressionId, request.target.column, - parsed.command.config, true); - break; - case LogicCommandTargetKind::Output: - break; - case LogicCommandTargetKind::ExistingNode: - break; + const LadderRung *target_rung = editor_service_.findRung( + request.logicId, request.target.rungId); + const bool has_condition = target_rung != nullptr + && std::any_of( + target_rung->cells.cbegin(), target_rung->cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Node; + }); + if (!has_condition) + { + return executionFailure( + "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); + } } + const LogicEditResult applied = + editor_service_.applyConditionAndAdvance( + request.logicId, + {request.continuing + ? request.currentRungId : request.target.rungId, + request.target.column, + false}, + parsed.command.config, + true); + edited = applied.edit; + next_cursor = applied.nextCursor; + has_next_cursor = edited.succeeded; } } else @@ -573,8 +558,14 @@ LogicCommandResult LogicCommandService::execute( "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", opcode); } - edited = editor_service_.setOutput( - request.logicId, rung_id, parsed.command.config, true); + const LogicEditResult applied = editor_service_.applyOutputAndAdvance( + request.logicId, + {rung_id, ProjectLimits::kMaximumConditionColumns, true}, + parsed.command.config, + true); + edited = applied.edit; + next_cursor = applied.nextCursor; + has_next_cursor = edited.succeeded; } if (!edited.succeeded) @@ -584,5 +575,7 @@ LogicCommandResult LogicCommandService::execute( return { true, {}, edited.id, editor_service_.rungIdForNode(request.logicId, edited.id), - opcode}; + opcode, + std::move(next_cursor), + has_next_cursor}; } diff --git a/app/src/services/logic_command_service.h b/app/src/services/logic_command_service.h index 129e20d..70b28f5 100644 --- a/app/src/services/logic_command_service.h +++ b/app/src/services/logic_command_service.h @@ -1,12 +1,10 @@ #pragma once -#include "domain/control_logic_model.h" +#include "logic_editor_service.h" #include #include -class LogicEditorService; - enum class LogicCommandOpcode { Load, @@ -88,6 +86,8 @@ struct LogicCommandResult std::string id; std::string rungId; LogicCommandOpcode opcode = LogicCommandOpcode::Load; + LogicEditCursor nextCursor; + bool hasNextCursor = false; }; // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index bad7481..cb02b50 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -1,15 +1,14 @@ #include "logic_editor_service.h" #include "project_service.h" -#include "domain/project_limits.h" #include #include -#include -#include #include -#include +#include #include +#include +#include #include namespace { @@ -19,7 +18,10 @@ bool isBlank(const std::string &value) return value.empty() || std::all_of( value.cbegin(), value.cend(), - [](unsigned char character) { return std::isspace(character) != 0; }); + [](unsigned char character) + { + return std::isspace(character) != 0; + }); } bool containsLineBreak(const std::string &value) @@ -30,677 +32,462 @@ bool containsLineBreak(const std::string &value) std::string makeUniqueLogicId(const Project &project) { - int suffix = 1; - while (true) + for (std::size_t suffix = 1U;; ++suffix) { const std::string candidate = "logic-" + std::to_string(suffix); - const bool found = std::any_of( - project.controlLogics.cbegin(), project.controlLogics.cend(), - [&candidate](const ControlLogic &logic) { return logic.id == candidate; }); - if (!found) + if (std::none_of( + project.controlLogics.cbegin(), project.controlLogics.cend(), + [&candidate](const ControlLogic &logic) + { + return logic.id == candidate; + })) { return candidate; } - ++suffix; } } std::size_t totalRungCount(const Project &project) { - std::size_t total = 0U; + std::size_t count = 0U; for (const ControlLogic &logic : project.controlLogics) { - total += logic.rungs.size(); + count += logic.rungs.size(); } - return total; -} - -std::string rungLimitMessage( - const Project &project, - const ControlLogic &logic, - const ProjectLimitSettings &limits) -{ - return logic.rungs.size() >= limits.maximumRungsPerLogic - ? "当前配置下单组控制逻辑最多允许 " - + std::to_string(limits.maximumRungsPerLogic) + " 个网络" - : totalRungCount(project) >= ProjectLimits::kMaximumRungsPerProject - ? "单个工程最多包含 2048 个梯形图网络" - : std::string{}; -} - -LogicNode makeNode( - const std::string &id, - const LogicNodeConfig &config, - bool configured = false) -{ - return {id, config, configured}; -} - -ConditionExpression makeContainer( - const std::string &id, - ConditionExpressionKind kind, - ConditionExpression first, - ConditionExpression second) -{ - ConditionExpression expression; - expression.id = id; - expression.kind = kind; - expression.children.push_back(std::move(first)); - expression.children.push_back(std::move(second)); - return expression; + return count; } -ConditionExpression *findParentExpression( - ConditionExpression &expression, const std::string &child_id) +ControlLogic *editableLogic(Project *project, const std::string &logic_id) { - for (ConditionExpression &child : expression.children) + if (project == nullptr) { - if (child.id == child_id) - { - return &expression; - } - if (ConditionExpression *parent = findParentExpression(child, child_id)) - { - return parent; - } + return nullptr; } - return nullptr; + const auto found = std::find_if( + project->controlLogics.begin(), project->controlLogics.end(), + [&logic_id](const ControlLogic &logic) { return logic.id == logic_id; }); + return found == project->controlLogics.end() ? nullptr : &*found; } -const ConditionExpression *findParentExpression( - const ConditionExpression &expression, const std::string &child_id) +LadderRung *editableRung(ControlLogic *logic, const std::string &rung_id) { - for (const ConditionExpression &child : expression.children) + if (logic == nullptr) { - if (child.id == child_id) - { - return &expression; - } - if (const ConditionExpression *parent = findParentExpression( - child, child_id)) - { - return parent; - } + return nullptr; } - return nullptr; + const auto found = std::find_if( + logic->rungs.begin(), logic->rungs.end(), + [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }); + return found == logic->rungs.end() ? nullptr : &*found; } -using NodeIdSet = std::unordered_set; - -NodeIdSet conditionLeafIds(const ConditionExpression &expression) +std::size_t rungIndex(const ControlLogic &logic, const std::string &rung_id) { - NodeIdSet ids; - if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire) - { - ids.insert(expression.id); - return ids; - } - for (const ConditionExpression &child : expression.children) - { - NodeIdSet child_ids = conditionLeafIds(child); - ids.insert(child_ids.cbegin(), child_ids.cend()); - } - return ids; + const auto found = std::find_if( + logic.rungs.cbegin(), logic.rungs.cend(), + [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }); + return found == logic.rungs.cend() + ? logic.rungs.size() + : static_cast(std::distance(logic.rungs.cbegin(), found)); } -int expressionColumns(const ConditionExpression &expression) +bool connectionMatches( + const VerticalConnection &connection, + const std::string &upper_rung_id, + const std::string &lower_rung_id, + int column_boundary) { - if (expression.kind == ConditionExpressionKind::Node) - { - return 1; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - return expression.wire->columnSpan; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return expression.gap->columnSpan; - } - int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; - for (const ConditionExpression &child : expression.children) - { - const int child_columns = expressionColumns(child); - columns = expression.kind == ConditionExpressionKind::Series - ? columns + child_columns : std::max(columns, child_columns); - } - return columns; + return connection.upperRungId == upper_rung_id + && connection.lowerRungId == lower_rung_id + && connection.columnBoundary == column_boundary; } -std::optional> firstGapCell( - const ConditionExpression &expression) +struct SyntaxConnectivity { - if (expression.kind == ConditionExpressionKind::Gap) + std::size_t rowCount = 0U; + std::vector forwardBase; + std::vector forward; + std::vector backwardBase; + std::vector backward; + std::vector verticalByBoundaryAndUpperRow; + + std::size_t stateIndex(std::size_t row, int boundary) const { - return std::make_pair(expression.id, 0); + return row * static_cast( + ProjectLimits::kMaximumConditionColumns + 1) + + static_cast(boundary); } - if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire) + + bool stateAt( + const std::vector &states, + std::size_t row, + int boundary) const { - return std::nullopt; + return states[stateIndex(row, boundary)] != 0U; } - for (const ConditionExpression &child : expression.children) + + const VerticalConnection *verticalAt( + int boundary, std::size_t upper_row) const { - if (const auto gap = firstGapCell(child); gap.has_value()) + if (rowCount < 2U || upper_row + 1U >= rowCount) { - return gap; + return nullptr; } + return verticalByBoundaryAndUpperRow[ + static_cast(boundary) * (rowCount - 1U) + upper_row]; } - return std::nullopt; -} - -bool isSubset(const NodeIdSet &subset, const NodeIdSet &values) -{ - return std::all_of( - subset.cbegin(), subset.cend(), - [&values](const std::string &value) { return values.count(value) != 0U; }); -} +}; -bool hasContiguousDirectNodeRange( - const ConditionExpression &expression, - const NodeIdSet &selected_ids) +SyntaxConnectivity analyzeConnectivity(const ControlLogic &logic) { - if (expression.kind == ConditionExpressionKind::Series) - { - std::vector matching_indices; - for (std::size_t index = 0; index < expression.children.size(); ++index) - { - const ConditionExpression &child = expression.children[index]; - if (child.kind == ConditionExpressionKind::Node - && selected_ids.count(child.node->id) != 0U) - { - matching_indices.push_back(index); - } - } - if (matching_indices.size() == selected_ids.size() - && !matching_indices.empty() - && matching_indices.back() - matching_indices.front() + 1U - == matching_indices.size()) - { - return true; - } - } - for (const ConditionExpression &child : expression.children) + SyntaxConnectivity analysis; + analysis.rowCount = logic.rungs.size(); + const std::size_t state_count = analysis.rowCount + * static_cast( + ProjectLimits::kMaximumConditionColumns + 1); + analysis.forwardBase.assign(state_count, 0U); + analysis.forward.assign(state_count, 0U); + analysis.backwardBase.assign(state_count, 0U); + analysis.backward.assign(state_count, 0U); + if (analysis.rowCount == 0U) { - if (hasContiguousDirectNodeRange(child, selected_ids)) - { - return true; - } + return analysis; } - return false; -} - -bool sameValues(const NodeIdSet &left, const NodeIdSet &right) -{ - return left.size() == right.size() && isSubset(left, right); -} -void addParallelSibling( - ConditionExpression *target, - ConditionExpression branch, - const std::string &container_id) -{ - if (target->kind == ConditionExpressionKind::Parallel) + analysis.verticalByBoundaryAndUpperRow.assign( + static_cast( + ProjectLimits::kMaximumConditionColumns + 1) + * (analysis.rowCount - 1U), + nullptr); + std::unordered_map row_indices; + row_indices.reserve(analysis.rowCount); + for (std::size_t row = 0U; row < analysis.rowCount; ++row) { - target->children.push_back(std::move(branch)); - return; + row_indices.emplace(logic.rungs[row].id, row); } - ConditionExpression original = std::move(*target); - *target = makeContainer( - container_id, - ConditionExpressionKind::Parallel, - std::move(original), - std::move(branch)); -} - -bool addParallelForSelection( - ConditionExpression *expression, - const NodeIdSet &selected_ids, - ConditionExpression *branch, - const std::string ¶llel_id, - const std::string &series_id) -{ - const NodeIdSet expression_ids = conditionLeafIds(*expression); - if (sameValues(expression_ids, selected_ids)) + for (const VerticalConnection &connection : logic.verticalConnections) { - addParallelSibling(expression, std::move(*branch), parallel_id); - return true; + const std::size_t upper = row_indices.at(connection.upperRungId); + analysis.verticalByBoundaryAndUpperRow[ + static_cast(connection.columnBoundary) + * (analysis.rowCount - 1U) + + upper] = &connection; } - std::vector child_ids; - child_ids.reserve(expression->children.size()); - std::vector matching_children; - for (std::size_t index = 0; index < expression->children.size(); ++index) + const auto mergeBoundaryComponents = [&analysis]( + int boundary, + const std::vector &base, + std::vector *merged) { - child_ids.push_back(conditionLeafIds(expression->children[index])); - const bool intersects = std::any_of( - child_ids.back().cbegin(), child_ids.back().cend(), - [&selected_ids](const std::string &id) - { - return selected_ids.count(id) != 0U; - }); - if (intersects) + std::size_t first = 0U; + while (first < analysis.rowCount) { - matching_children.push_back(index); + std::size_t last = first; + while (last + 1U < analysis.rowCount + && analysis.verticalAt(boundary, last) != nullptr) + { + ++last; + } + bool active = false; + for (std::size_t row = first; row <= last; ++row) + { + active = active || analysis.stateAt(base, row, boundary); + } + for (std::size_t row = first; row <= last; ++row) + { + (*merged)[analysis.stateIndex(row, boundary)] = active ? 1U : 0U; + } + first = last + 1U; } - } - if (matching_children.empty()) - { - return false; - } - if (matching_children.size() == 1U) + }; + + for (int boundary = 0; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) { - const std::size_t child_index = matching_children.front(); - if (expression->kind == ConditionExpressionKind::Parallel - && sameValues(child_ids[child_index], selected_ids)) + for (std::size_t row = 0U; row < analysis.rowCount; ++row) { - expression->children.push_back(std::move(*branch)); - return true; + const bool active = boundary == 0 + || (analysis.stateAt(analysis.forward, row, boundary - 1) + && logic.rungs[row] + .cells[static_cast(boundary - 1)] + .kind != LadderCellKind::Gap); + analysis.forwardBase[analysis.stateIndex(row, boundary)] = + active ? 1U : 0U; } - return addParallelForSelection( - &expression->children[child_index], - selected_ids, - branch, - parallel_id, - series_id); - } - if (expression->kind != ConditionExpressionKind::Series) - { - return false; + mergeBoundaryComponents( + boundary, analysis.forwardBase, &analysis.forward); } - const std::size_t first = matching_children.front(); - const std::size_t last = matching_children.back(); - if (last - first + 1U != matching_children.size()) - { - return false; - } - NodeIdSet range_ids; - for (std::size_t index = first; index <= last; ++index) - { - range_ids.insert(child_ids[index].cbegin(), child_ids[index].cend()); - } - if (!sameValues(range_ids, selected_ids)) + for (int boundary = ProjectLimits::kMaximumConditionColumns; + boundary >= 0; + --boundary) { - return false; + for (std::size_t row = 0U; row < analysis.rowCount; ++row) + { + const bool active = boundary + == ProjectLimits::kMaximumConditionColumns + ? logic.rungs[row].output.has_value() + : logic.rungs[row] + .cells[static_cast(boundary)] + .kind != LadderCellKind::Gap + && analysis.stateAt( + analysis.backward, row, boundary + 1); + analysis.backwardBase[analysis.stateIndex(row, boundary)] = + active ? 1U : 0U; + } + mergeBoundaryComponents( + boundary, analysis.backwardBase, &analysis.backward); } - - ConditionExpression range; - range.id = series_id; - range.kind = ConditionExpressionKind::Series; - auto range_begin = expression->children.begin() + static_cast(first); - auto range_end = expression->children.begin() + static_cast(last + 1U); - range.children.insert( - range.children.end(), - std::make_move_iterator(range_begin), - std::make_move_iterator(range_end)); - range_begin = expression->children.erase(range_begin, range_end); - - ConditionExpression parallel = makeContainer( - parallel_id, - ConditionExpressionKind::Parallel, - std::move(range), - std::move(*branch)); - expression->children.insert(range_begin, std::move(parallel)); - return true; + return analysis; } -bool addParallelForWireCellRange( - ConditionExpression *expression, - const std::string &wire_id, - int first_column, - int selected_columns, - ConditionExpression *branch, - const std::string ¶llel_id, - const std::string &series_id, - const std::string &leading_wire_id, - const std::string &trailing_wire_id) +std::vector networkNumbersByRow(const ControlLogic &logic) { - if (expression == nullptr) - { - return false; - } - if (expression->kind == ConditionExpressionKind::Wire - && expression->id == wire_id) - { - const int total_columns = expression->wire->columnSpan; - if (first_column < 0 || selected_columns <= 0 - || first_column + selected_columns > total_columns) - { - return false; - } - if (first_column == 0 && selected_columns == total_columns) - { - addParallelSibling(expression, std::move(*branch), parallel_id); - return true; - } - - ConditionExpression replacement; - replacement.id = series_id; - replacement.kind = ConditionExpressionKind::Series; - if (first_column > 0) - { - replacement.children.push_back(ConditionExpression::fromWire( - leading_wire_id, first_column)); - } - ConditionExpression selected_wire = ConditionExpression::fromWire( - wire_id, selected_columns); - replacement.children.push_back(makeContainer( - parallel_id, - ConditionExpressionKind::Parallel, - std::move(selected_wire), - std::move(*branch))); - const int trailing_columns = - total_columns - first_column - selected_columns; - if (trailing_columns > 0) - { - replacement.children.push_back(ConditionExpression::fromWire( - trailing_wire_id, trailing_columns)); - } - *expression = std::move(replacement); - return true; - } - if (expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire - || expression->kind == ConditionExpressionKind::Gap) - { - return false; - } - for (ConditionExpression &child : expression->children) + std::vector networks(logic.rungs.size(), 0); + int network = 0; + for (std::size_t row = 0U; row < logic.rungs.size(); ++row) { - if (addParallelForWireCellRange( - &child, - wire_id, - first_column, - selected_columns, - branch, - parallel_id, - series_id, - leading_wire_id, - trailing_wire_id)) + if (row > 0U) { - return true; + const std::string &upper = logic.rungs[row - 1U].id; + const std::string &lower = logic.rungs[row].id; + const bool connected = std::any_of( + logic.verticalConnections.cbegin(), + logic.verticalConnections.cend(), + [&upper, &lower](const VerticalConnection &connection) + { + return connection.upperRungId == upper + && connection.lowerRungId == lower; + }); + if (!connected) + { + ++network; + } } + networks[row] = network; } - return false; + return networks; } -struct WireCellLocation -{ - const ConditionExpression *series = nullptr; - std::size_t child_index = 0U; - int child_start = 0; -}; - -bool findWireCellLocation( - const ConditionExpression &expression, - const std::string &wire_id, - WireCellLocation *location) +std::unordered_set activeVerticalConnections( + const SyntaxConnectivity &analysis) { - if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire - || expression.kind == ConditionExpressionKind::Gap) + std::unordered_set active_connections; + for (int boundary = 0; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) { - return false; - } - - if (expression.kind == ConditionExpressionKind::Series) - { - int child_start = 0; - for (std::size_t index = 0; index < expression.children.size(); ++index) + std::size_t first = 0U; + while (first < analysis.rowCount) { - const ConditionExpression &child = expression.children[index]; - if (child.kind == ConditionExpressionKind::Wire - && child.id == wire_id) + std::size_t last = first; + while (last + 1U < analysis.rowCount + && analysis.verticalAt(boundary, last) != nullptr) { - if (location != nullptr) - { - *location = {&expression, index, child_start}; - } - return true; + ++last; } - if (findWireCellLocation(child, wire_id, location)) + int total_forward = 0; + int total_backward = 0; + for (std::size_t row = first; row <= last; ++row) { - return true; + total_forward += analysis.stateAt( + analysis.forwardBase, row, boundary) ? 1 : 0; + total_backward += analysis.stateAt( + analysis.backwardBase, row, boundary) ? 1 : 0; } - child_start += expressionColumns(child); - } - return false; - } - - for (const ConditionExpression &child : expression.children) - { - if (findWireCellLocation(child, wire_id, location)) - { - return true; + int upper_forward = 0; + int upper_backward = 0; + for (std::size_t upper = first; upper < last; ++upper) + { + upper_forward += analysis.stateAt( + analysis.forwardBase, upper, boundary) ? 1 : 0; + upper_backward += analysis.stateAt( + analysis.backwardBase, upper, boundary) ? 1 : 0; + const int lower_forward = total_forward - upper_forward; + const int lower_backward = total_backward - upper_backward; + if ((upper_forward > 0 && lower_backward > 0) + || (upper_backward > 0 && lower_forward > 0)) + { + active_connections.insert( + analysis.verticalAt(boundary, upper)->id); + } + } + first = last + 1U; } } - return false; + return active_connections; } -bool addParallelForWireCellSeriesRange( - ConditionExpression *root, - const std::string &series_id, - std::size_t first_child, - std::size_t last_child, - int leading_columns, - int selected_columns, - int trailing_columns, - ConditionExpression *branch, - const std::string &selected_wire_id, - const std::string ¶llel_id, - const std::string &leading_wire_id, - const std::string &trailing_wire_id) +struct LogicCleanupStats { - if (root == nullptr || branch == nullptr) - { - return false; - } - ConditionExpression *series = findConditionExpression(*root, series_id); - if (series == nullptr || series->kind != ConditionExpressionKind::Series - || first_child > last_child || last_child >= series->children.size() - || leading_columns < 0 || selected_columns <= 0 || trailing_columns < 0) + std::size_t wireCells = 0U; + std::size_t verticalConnections = 0U; +}; + +LogicCleanupStats normalizeLogicWires(ControlLogic *logic) +{ + LogicCleanupStats stats; + if (logic == nullptr || logic->rungs.empty()) { - return false; + return stats; } - for (std::size_t index = first_child; index <= last_child; ++index) + const SyntaxConnectivity analysis = analyzeConnectivity(*logic); + const std::vector networks = networkNumbersByRow(*logic); + const int network_count = networks.empty() ? 0 : networks.back() + 1; + std::vector invalid_networks( + static_cast(network_count), 0U); + for (std::size_t row = 0U; row < logic->rungs.size(); ++row) { - if (series->children[index].kind != ConditionExpressionKind::Wire) + if (logic->rungs[row].output.has_value() + && !analysis.stateAt( + analysis.forward, + row, + ProjectLimits::kMaximumConditionColumns)) { - return false; + invalid_networks[static_cast(networks[row])] = 1U; } } - ConditionExpression selected_wire = ConditionExpression::fromWire( - selected_wire_id, selected_columns); - ConditionExpression parallel = makeContainer( - parallel_id, - ConditionExpressionKind::Parallel, - std::move(selected_wire), - std::move(*branch)); - std::vector replacement; - replacement.reserve( - series->children.size() - (last_child - first_child) + 3U); - for (std::size_t index = 0; index < first_child; ++index) - { - replacement.push_back(std::move(series->children[index])); - } - if (leading_columns > 0) - { - replacement.push_back(ConditionExpression::fromWire( - leading_wire_id, leading_columns)); - } - replacement.push_back(std::move(parallel)); - if (trailing_columns > 0) - { - replacement.push_back(ConditionExpression::fromWire( - trailing_wire_id, trailing_columns)); - } - for (std::size_t index = last_child + 1U; - index < series->children.size(); - ++index) + const std::unordered_set active_verticals = + activeVerticalConnections(analysis); + std::unordered_map row_indices; + row_indices.reserve(logic->rungs.size()); + for (std::size_t row = 0U; row < logic->rungs.size(); ++row) { - replacement.push_back(std::move(series->children[index])); + row_indices.emplace(logic->rungs[row].id, row); } - series->children = std::move(replacement); - return true; -} - -bool removeExpressionRecursive( - ConditionExpression *expression, const std::string &expression_id) -{ - if (expression == nullptr - || expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire - || expression->kind == ConditionExpressionKind::Gap) + for (std::size_t row = 0U; row < logic->rungs.size(); ++row) { - return false; - } - const auto removable = std::find_if( - expression->children.begin(), - expression->children.end(), - [&expression_id](const ConditionExpression &child) + if (invalid_networks[static_cast(networks[row])] != 0U) { - return child.id == expression_id; - }); - if (removable != expression->children.end()) - { - expression->children.erase(removable); - return true; - } - for (ConditionExpression &child : expression->children) - { - if (removeExpressionRecursive(&child, expression_id)) + continue; + } + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - return true; + LadderCell &cell = logic->rungs[row] + .cells[static_cast(column)]; + if (cell.kind == LadderCellKind::Wire + && !(analysis.stateAt(analysis.forward, row, column) + && analysis.stateAt( + analysis.backward, row, column + 1))) + { + cell.kind = LadderCellKind::Gap; + ++stats.wireCells; + } } } - return false; + + const auto new_end = std::remove_if( + logic->verticalConnections.begin(), + logic->verticalConnections.end(), + [&row_indices, &networks, &invalid_networks, &active_verticals, &stats]( + const VerticalConnection &connection) + { + const std::size_t upper_row = row_indices.at(connection.upperRungId); + if (invalid_networks[ + static_cast(networks[upper_row])] != 0U + || active_verticals.find(connection.id) + != active_verticals.end()) + { + return false; + } + ++stats.verticalConnections; + return true; + }); + logic->verticalConnections.erase(new_end, logic->verticalConnections.end()); + return stats; } -std::optional selectedExpressionColumns( - const ConditionExpression &expression, - const NodeIdSet &selected_ids) +std::optional> firstSyntaxIssue( + const ControlLogic &logic) { - const NodeIdSet expression_ids = conditionLeafIds(expression); - if (sameValues(expression_ids, selected_ids)) + const std::vector networks = networkNumbersByRow(logic); + for (std::size_t row = 0U; row < logic.rungs.size(); ++row) { - return expressionColumns(expression); - } - if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire - || expression.kind == ConditionExpressionKind::Gap) - { - return std::nullopt; - } - - std::vector child_ids; - std::vector matching_children; - child_ids.reserve(expression.children.size()); - for (std::size_t index = 0; index < expression.children.size(); ++index) - { - child_ids.push_back(conditionLeafIds(expression.children[index])); - const bool intersects = std::any_of( - child_ids.back().cbegin(), child_ids.back().cend(), - [&selected_ids](const std::string &id) + const LadderRung &rung = logic.rungs[row]; + for (std::size_t column = 0U; column < rung.cells.size(); ++column) + { + if (rung.cells[column].node.has_value() + && !rung.cells[column].node->isConfigured()) { - return selected_ids.count(id) != 0U; - }); - if (intersects) + LogicSyntaxLocation location{ + logic.id, + rung.id, + networks[row] + 1, + static_cast(row + 1U), + static_cast(column + 1U)}; + return std::make_pair( + location, + "控制逻辑 " + logic.name + " 的网络 " + + std::to_string(location.network) + ",第 " + + std::to_string(location.row) + " 行第 " + + std::to_string(location.column) + + " 列:程序语法分析发生错误,条件指令尚未配置"); + } + } + if (rung.output.has_value() && !rung.output->isConfigured()) { - matching_children.push_back(index); + LogicSyntaxLocation location{ + logic.id, + rung.id, + networks[row] + 1, + static_cast(row + 1U), + ProjectLimits::kMaximumLadderColumns}; + return std::make_pair( + location, + "控制逻辑 " + logic.name + " 的网络 " + + std::to_string(location.network) + ",第 " + + std::to_string(location.row) + " 行第 " + + std::to_string(location.column) + + " 列:程序语法分析发生错误,输出指令尚未配置"); } } - if (matching_children.size() == 1U) - { - return selectedExpressionColumns( - expression.children[matching_children.front()], selected_ids); - } - if (matching_children.empty() - || expression.kind != ConditionExpressionKind::Series) - { - return std::nullopt; - } - const std::size_t first = matching_children.front(); - const std::size_t last = matching_children.back(); - if (last - first + 1U != matching_children.size()) - { - return std::nullopt; - } - NodeIdSet range_ids; - int columns = 0; - for (std::size_t index = first; index <= last; ++index) - { - range_ids.insert(child_ids[index].cbegin(), child_ids[index].cend()); - columns += expressionColumns(expression.children[index]); - } - return sameValues(range_ids, selected_ids) - ? std::optional{columns} : std::nullopt; -} - -LadderRung *findEditableRung( - Project &project, - const std::string &logic_id, - const std::string &rung_id) -{ - const auto logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (logic == project.controlLogics.end()) - { - return nullptr; - } - const auto rung = std::find_if( - logic->rungs.begin(), logic->rungs.end(), - [&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; }); - return rung == logic->rungs.end() ? nullptr : &*rung; -} -bool trimTrailingGridColumns(ConditionExpression *expression, int *columns) -{ - if (expression == nullptr || columns == nullptr || *columns <= 0) - { - return true; - } - auto trim_leaf = [columns](int *span) - { - const int removed = std::min(*span, *columns); - *span -= removed; - *columns -= removed; - }; - if (expression->kind == ConditionExpressionKind::Wire) - { - trim_leaf(&expression->wire->columnSpan); - return *columns == 0 && expression->wire->columnSpan > 0; - } - if (expression->kind == ConditionExpressionKind::Gap) - { - trim_leaf(&expression->gap->columnSpan); - return *columns == 0 && expression->gap->columnSpan > 0; - } - if (expression->kind != ConditionExpressionKind::Series) - { - return false; - } - while (*columns > 0 && !expression->children.empty()) + const SyntaxConnectivity analysis = analyzeConnectivity(logic); + for (std::size_t row = 0U; row < logic.rungs.size(); ++row) { - ConditionExpression &last = expression->children.back(); - int *span = last.kind == ConditionExpressionKind::Wire - ? &last.wire->columnSpan - : last.kind == ConditionExpressionKind::Gap - ? &last.gap->columnSpan : nullptr; - if (span == nullptr) + const LadderRung &rung = logic.rungs[row]; + if (!rung.output.has_value() + || analysis.stateAt( + analysis.forward, + row, + ProjectLimits::kMaximumConditionColumns)) { - return false; + continue; } - trim_leaf(span); - if (*span == 0) + int last_reachable_boundary = 0; + for (int boundary = 0; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) { - expression->children.pop_back(); + if (analysis.stateAt(analysis.forward, row, boundary)) + { + last_reachable_boundary = boundary; + } } + const int disconnected_column = std::min( + last_reachable_boundary + 1, + ProjectLimits::kMaximumConditionColumns); + LogicSyntaxLocation location{ + logic.id, + rung.id, + networks[row] + 1, + static_cast(row + 1U), + ProjectLimits::kMaximumLadderColumns}; + return std::make_pair( + location, + "控制逻辑 " + logic.name + " 的网络 " + + std::to_string(location.network) + ",第 " + + std::to_string(location.row) + " 行第 " + + std::to_string(location.column) + + " 列:程序语法分析发生错误,输出路径从第 " + + std::to_string(disconnected_column) + + " 列起断开,未连接到左母线"); } - return *columns == 0 && !expression->children.empty(); + return std::nullopt; } } // namespace @@ -717,12 +504,8 @@ LogicEditorService::HistoryState LogicEditorService::captureState() const void LogicEditorService::recordHistory(HistoryState before) { - if (suppress_history_) - { - return; - } - const HistoryState after = captureState(); - history_.record(std::move(before), after, &LogicEditorService::statesEqual); + history_.record( + std::move(before), captureState(), &LogicEditorService::statesEqual); } void LogicEditorService::rollbackEdit( @@ -739,7 +522,7 @@ bool LogicEditorService::statesEqual( { return false; } - for (std::size_t index = 0; index < left.logics.size(); ++index) + for (std::size_t index = 0U; index < left.logics.size(); ++index) { if (!logicsEqual(left.logics[index], right.logics[index])) { @@ -753,17 +536,36 @@ bool LogicEditorService::logicsEqual( const ControlLogic &left, const ControlLogic &right) { if (left.id != right.id || left.name != right.name - || left.enabled != right.enabled || left.rungs.size() != right.rungs.size()) + || left.enabled != right.enabled + || left.rungs.size() != right.rungs.size() + || left.verticalConnections.size() != right.verticalConnections.size()) { return false; } - for (std::size_t index = 0; index < left.rungs.size(); ++index) + for (std::size_t index = 0U; index < left.rungs.size(); ++index) { if (!rungsEqual(left.rungs[index], right.rungs[index])) { return false; } } + for (std::size_t index = 0U; + index < left.verticalConnections.size(); + ++index) + { + const VerticalConnection &left_connection = + left.verticalConnections[index]; + const VerticalConnection &right_connection = + right.verticalConnections[index]; + if (left_connection.id != right_connection.id + || left_connection.upperRungId != right_connection.upperRungId + || left_connection.lowerRungId != right_connection.lowerRungId + || left_connection.columnBoundary + != right_connection.columnBoundary) + { + return false; + } + } return true; } @@ -772,56 +574,35 @@ bool LogicEditorService::rungsEqual( { if (left.id != right.id || left.name != right.name || left.comment != right.comment - || left.condition.has_value() != right.condition.has_value() + || left.cells.size() != right.cells.size() || left.output.has_value() != right.output.has_value()) { return false; } - return (!left.condition.has_value() - || expressionsEqual(*left.condition, *right.condition)) - && (!left.output.has_value() || nodesEqual(*left.output, *right.output)); -} - -bool LogicEditorService::expressionsEqual( - const ConditionExpression &left, const ConditionExpression &right) -{ - if (left.id != right.id || left.kind != right.kind - || left.node.has_value() != right.node.has_value() - || left.wire.has_value() != right.wire.has_value() - || left.gap.has_value() != right.gap.has_value() - || left.children.size() != right.children.size()) - { - return false; - } - if (left.node.has_value() && !nodesEqual(*left.node, *right.node)) - { - return false; - } - if (left.wire.has_value() - && left.wire->columnSpan != right.wire->columnSpan) - { - return false; - } - if (left.gap.has_value() - && left.gap->columnSpan != right.gap->columnSpan) + for (std::size_t index = 0U; index < left.cells.size(); ++index) { - return false; - } - for (std::size_t index = 0; index < left.children.size(); ++index) - { - if (!expressionsEqual(left.children[index], right.children[index])) + if (!cellsEqual(left.cells[index], right.cells[index])) { return false; } } - return true; + return !left.output.has_value() + || nodesEqual(*left.output, *right.output); +} + +bool LogicEditorService::cellsEqual( + const LadderCell &left, const LadderCell &right) +{ + return left.id == right.id && left.kind == right.kind + && left.node.has_value() == right.node.has_value() + && (!left.node.has_value() || nodesEqual(*left.node, *right.node)); } bool LogicEditorService::nodesEqual( const LogicNode &left, const LogicNode &right) { - return left.id == right.id && left.config.index() == right.config.index() - && left.configured == right.configured && configsEqual(left.config, right.config); + return left.id == right.id && left.configured == right.configured + && configsEqual(left.config, right.config); } bool LogicEditorService::configsEqual( @@ -880,18 +661,20 @@ bool LogicEditorService::configsEqual( right); } -LogicEditorResult LogicEditorService::historyFailure(const std::string &message) +LogicEditorResult LogicEditorService::historyFailure( + const std::string &message) { return {false, LogicEditorError::InvalidOperation, message, {}}; } -const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const +const ControlLogic *LogicEditorService::findLogic( + const std::string &logic_id) const { const auto &logics = project_service_.project().controlLogics; - const auto logic = std::find_if( + const auto found = std::find_if( logics.cbegin(), logics.cend(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - return logic == logics.cend() ? nullptr : &*logic; + [&logic_id](const ControlLogic &logic) { return logic.id == logic_id; }); + return found == logics.cend() ? nullptr : &*found; } const LadderRung *LogicEditorService::findRung( @@ -902,13 +685,43 @@ const LadderRung *LogicEditorService::findRung( { return nullptr; } - const auto rung = std::find_if( + const auto found = std::find_if( logic->rungs.cbegin(), logic->rungs.cend(), - [&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; }); - return rung == logic->rungs.cend() ? nullptr : &*rung; + [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }); + return found == logic->rungs.cend() ? nullptr : &*found; } -const LogicNode *LogicEditorService::findNode( +const LadderCell *LogicEditorService::findCell( + const std::string &logic_id, + const std::string &rung_id, + int column) const +{ + const LadderRung *rung = findRung(logic_id, rung_id); + return rung == nullptr || column < 0 + || column >= static_cast(rung->cells.size()) + ? nullptr + : &rung->cells[static_cast(column)]; +} + +const LadderCell *LogicEditorService::findCell( + const std::string &logic_id, + const std::string &rung_id, + const std::string &cell_id) const +{ + const LadderRung *rung = findRung(logic_id, rung_id); + return rung == nullptr ? nullptr : findLadderCell(*rung, cell_id); +} + +const VerticalConnection *LogicEditorService::findConnection( + const std::string &logic_id, + const std::string &connection_id) const +{ + const ControlLogic *logic = findLogic(logic_id); + return logic == nullptr + ? nullptr : findVerticalConnection(*logic, connection_id); +} + +const LogicNode *LogicEditorService::findNode( const std::string &logic_id, const std::string &node_id) const { const ControlLogic *logic = findLogic(logic_id); @@ -918,41 +731,33 @@ const LogicNode *LogicEditorService::findNode( } for (const LadderRung &rung : logic->rungs) { - if (rung.output.has_value() && rung.output->id == node_id) - { - return &*rung.output; - } - if (rung.condition.has_value()) + for (const LadderCell &cell : rung.cells) { - if (const LogicNode *node = findConditionNode(*rung.condition, node_id)) + if (cell.node.has_value() && cell.node->id == node_id) { - return node; + return &*cell.node; } } + if (rung.output.has_value() && rung.output->id == node_id) + { + return &*rung.output; + } } return nullptr; } -const ConditionExpression *LogicEditorService::findExpression( - const std::string &logic_id, - const std::string &rung_id, - const std::string &expression_id) const -{ - const LadderRung *rung = findRung(logic_id, rung_id); - return rung == nullptr || !rung->condition.has_value() - ? nullptr : findConditionExpression(*rung->condition, expression_id); -} - std::string LogicEditorService::firstLogicId() const { const auto &logics = project_service_.project().controlLogics; return logics.empty() ? std::string{} : logics.front().id; } -std::string LogicEditorService::firstRungId(const std::string &logic_id) const +std::string LogicEditorService::firstRungId( + const std::string &logic_id) const { const ControlLogic *logic = findLogic(logic_id); - return logic == nullptr || logic->rungs.empty() ? std::string{} : logic->rungs.front().id; + return logic == nullptr || logic->rungs.empty() + ? std::string{} : logic->rungs.front().id; } std::string LogicEditorService::rungIdForNode( @@ -965,12 +770,17 @@ std::string LogicEditorService::rungIdForNode( } for (const LadderRung &rung : logic->rungs) { - if ((rung.output.has_value() && rung.output->id == node_id) - || (rung.condition.has_value() - && findConditionNode(*rung.condition, node_id) != nullptr)) + if (rung.output.has_value() && rung.output->id == node_id) { return rung.id; } + for (const LadderCell &cell : rung.cells) + { + if (cell.node.has_value() && cell.node->id == node_id) + { + return rung.id; + } + } } return {}; } @@ -978,7 +788,8 @@ std::string LogicEditorService::rungIdForNode( std::string LogicEditorService::registerCommentFor( const RegisterAddress &address) const { - const RegisterComment *comment = project_service_.project().findRegisterComment(address); + const RegisterComment *comment = + project_service_.project().findRegisterComment(address); return comment == nullptr ? std::string{} : comment->text; } @@ -998,31 +809,26 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic() LogicEditorResult LogicEditorService::addLogic(const std::string &name) { - if (isBlank(name)) + if (isBlank(name) || name.size() > ProjectLimits::kMaximumTextBytes) { - return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); + return failure( + LogicEditorError::InvalidOperation, + "控制逻辑名称不能为空且不能超过 256 个 UTF-8 字节"); } const Project ¤t = project_service_.project(); - const ProjectLimitSettings &limits = project_service_.projectLimits(); - if (current.controlLogics.size() >= limits.maximumControlLogics) + if (current.controlLogics.size() + >= project_service_.projectLimits().maximumControlLogics) { return failure( LogicEditorError::InvalidOperation, - "当前配置最多允许 " - + std::to_string(limits.maximumControlLogics) + " 组控制逻辑"); - } - if (name.size() > ProjectLimits::kMaximumTextBytes) - { - return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 256 个 UTF-8 字节"); + "控制逻辑数量已经达到当前配置上限"); } - const bool duplicate = std::any_of( - current.controlLogics.cbegin(), current.controlLogics.cend(), - [&name](const ControlLogic &logic) { return logic.name == name; }); - if (duplicate) + if (std::any_of( + current.controlLogics.cbegin(), current.controlLogics.cend(), + [&name](const ControlLogic &logic) { return logic.name == name; })) { return failure(LogicEditorError::DuplicateName, "控制逻辑名称必须唯一"); } - ControlLogic logic; logic.id = makeUniqueLogicId(current); logic.name = name; @@ -1036,99 +842,98 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) LogicEditorResult LogicEditorService::renameLogic( const std::string &logic_id, const std::string &name) { - if (isBlank(name)) - { - return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); - } - if (name.size() > ProjectLimits::kMaximumTextBytes) + if (isBlank(name) || name.size() > ProjectLimits::kMaximumTextBytes) { - return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 256 个 UTF-8 字节"); + return failure( + LogicEditorError::InvalidOperation, + "控制逻辑名称不能为空且不能超过 256 个 UTF-8 字节"); } - const Project ¤t = project_service_.project(); - const auto logic = std::find_if( - current.controlLogics.cbegin(), current.controlLogics.cend(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (logic == current.controlLogics.cend()) + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const bool duplicate = std::any_of( - current.controlLogics.cbegin(), current.controlLogics.cend(), - [&logic_id, &name](const ControlLogic &candidate) - { - return candidate.id != logic_id && candidate.name == name; - }); - if (duplicate) + const Project ¤t = project_service_.project(); + if (std::any_of( + current.controlLogics.cbegin(), current.controlLogics.cend(), + [&logic_id, &name](const ControlLogic &candidate) + { + return candidate.id != logic_id && candidate.name == name; + })) { return failure(LogicEditorError::DuplicateName, "控制逻辑名称必须唯一"); } - if (logic->name == name) { return {true, LogicEditorError::None, {}, logic_id}; } HistoryState before = captureState(); Project &project = project_service_.editProject(); - auto editable = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - editable->name = name; + editableLogic(&project, logic_id)->name = name; recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } -LogicEditorResult LogicEditorService::removeLogic(const std::string &logic_id) +LogicEditorResult LogicEditorService::removeLogic( + const std::string &logic_id) { const Project ¤t = project_service_.project(); - const auto logic = std::find_if( - current.controlLogics.cbegin(), current.controlLogics.cend(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (logic == current.controlLogics.cend()) + if (findLogic(logic_id) == nullptr) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - if (current.controlLogics.size() <= 1) + if (current.controlLogics.size() <= 1U) { - return failure(LogicEditorError::LastLogicRequired, "工程至少需要保留一个控制逻辑"); + return failure( + LogicEditorError::LastLogicRequired, + "工程至少需要保留一个控制逻辑"); } HistoryState before = captureState(); Project &project = project_service_.editProject(); project.controlLogics.erase( std::remove_if( project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }), + [&logic_id](const ControlLogic &logic) + { + return logic.id == logic_id; + }), project.controlLogics.end()); recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } -LogicEditorResult LogicEditorService::moveLogic(const std::string &logic_id, int offset) +LogicEditorResult LogicEditorService::moveLogic( + const std::string &logic_id, int offset) { if (offset != -1 && offset != 1) { - return failure(LogicEditorError::InvalidOperation, "控制逻辑每次只能上移或下移一位"); + return failure( + LogicEditorError::InvalidOperation, + "控制逻辑每次只能上移或下移一位"); } const Project ¤t = project_service_.project(); - const auto logic = std::find_if( + const auto found = std::find_if( current.controlLogics.cbegin(), current.controlLogics.cend(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (logic == current.controlLogics.cend()) + [&logic_id](const ControlLogic &logic) { return logic.id == logic_id; }); + if (found == current.controlLogics.cend()) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const auto index = std::distance(current.controlLogics.cbegin(), logic); - const std::ptrdiff_t target_index = index + offset; - if (target_index < 0 - || target_index - >= static_cast(current.controlLogics.size())) + const std::ptrdiff_t index = + std::distance(current.controlLogics.cbegin(), found); + const std::ptrdiff_t target = index + offset; + if (target < 0 + || target >= static_cast(current.controlLogics.size())) { - return failure(LogicEditorError::InvalidOperation, "控制逻辑已经位于目标边界"); + return failure( + LogicEditorError::InvalidOperation, + "控制逻辑已经位于目标边界"); } HistoryState before = captureState(); Project &project = project_service_.editProject(); std::iter_swap( project.controlLogics.begin() + index, - project.controlLogics.begin() + target_index); + project.controlLogics.begin() + target); recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } @@ -1136,49 +941,224 @@ LogicEditorResult LogicEditorService::moveLogic(const std::string &logic_id, int LogicEditorResult LogicEditorService::setLogicEnabled( const std::string &logic_id, bool enabled) { - if (findLogic(logic_id) == nullptr) + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const ControlLogic *existing = findLogic(logic_id); - if (existing->enabled == enabled) + if (logic->enabled == enabled) { return {true, LogicEditorError::None, {}, logic_id}; } HistoryState before = captureState(); Project &project = project_service_.editProject(); - auto logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - logic->enabled = enabled; + editableLogic(&project, logic_id)->enabled = enabled; recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } +LogicSyntaxCheckResult LogicEditorService::checkSyntax( + const std::string &logic_id) +{ + if (findLogic(logic_id) == nullptr) + { + LogicSyntaxCheckResult result; + result.message = "未找到要检查的控制逻辑"; + return result; + } + return checkSyntaxForLogics({logic_id}); +} + +LogicSyntaxCheckResult LogicEditorService::checkDoubleCoils( + const std::string &logic_id) const +{ + LogicSyntaxCheckResult result; + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) + { + result.message = "未找到要检查的控制逻辑"; + return result; + } + result.completed = true; + result.valid = true; + result.checkedLogicCount = 1U; + const std::vector networks = networkNumbersByRow(*logic); + std::map first_rows_by_address; + for (std::size_t row = 0U; row < logic->rungs.size(); ++row) + { + const LadderRung &rung = logic->rungs[row]; + if (!rung.output.has_value()) + { + continue; + } + const auto *coil = std::get_if(&rung.output->config); + if (coil == nullptr) + { + continue; + } + const auto inserted = first_rows_by_address.emplace( + coil->address.index(), row); + if (inserted.second) + { + continue; + } + const std::size_t first_row = inserted.first->second; + LogicSyntaxLocation location{ + logic->id, + rung.id, + networks[row] + 1, + static_cast(row + 1U), + ProjectLimits::kMaximumLadderColumns}; + result.valid = false; + result.location = location; + result.message = "控制逻辑 " + logic->name + " 的网络 " + + std::to_string(location.network) + ",第 " + + std::to_string(location.row) + " 行第 " + + std::to_string(location.column) + " 列:发现双线圈输出 " + + coil->address.toString() + ",首次输出位于第 " + + std::to_string(first_row + 1U) + " 行"; + return result; + } + result.message = "双线圈检查通过"; + return result; +} + +LogicSyntaxCheckResult LogicEditorService::checkEnabledSyntax() +{ + std::vector logic_ids; + for (const ControlLogic &logic : project_service_.project().controlLogics) + { + if (logic.enabled) + { + logic_ids.push_back(logic.id); + } + } + return checkSyntaxForLogics(logic_ids); +} + +LogicSyntaxCheckResult LogicEditorService::checkSyntaxForLogics( + const std::vector &logic_ids) +{ + LogicSyntaxCheckResult result; + result.checkedLogicCount = logic_ids.size(); + HistoryState before = captureState(); + HistoryState candidate = before; + + for (const std::string &logic_id : logic_ids) + { + ControlLogic *logic = nullptr; + const auto found = std::find_if( + candidate.logics.begin(), + candidate.logics.end(), + [&logic_id](const ControlLogic &item) { return item.id == logic_id; }); + if (found != candidate.logics.end()) + { + logic = &*found; + } + if (logic == nullptr) + { + result.message = "语法检查期间未找到控制逻辑"; + return result; + } + std::string structure_error; + if (!logic->validateStructure( + project_service_.projectLimits(), &structure_error)) + { + result.completed = true; + result.message = "程序语法分析发生错误:" + structure_error; + return result; + } + } + + for (const std::string &logic_id : logic_ids) + { + const auto found = std::find_if( + candidate.logics.begin(), + candidate.logics.end(), + [&logic_id](const ControlLogic &item) { return item.id == logic_id; }); + const LogicCleanupStats stats = normalizeLogicWires(&*found); + result.removedWireCells += stats.wireCells; + result.removedVerticalConnections += stats.verticalConnections; + } + + for (const std::string &logic_id : logic_ids) + { + const auto found = std::find_if( + candidate.logics.cbegin(), + candidate.logics.cend(), + [&logic_id](const ControlLogic &item) { return item.id == logic_id; }); + const auto issue = firstSyntaxIssue(*found); + if (issue.has_value()) + { + result.location = issue->first; + result.message = issue->second; + break; + } + } + + result.completed = true; + result.valid = !result.location.has_value(); + result.changed = !statesEqual(before, candidate); + if (result.changed) + { + project_service_.editProject().controlLogics = std::move(candidate.logics); + recordHistory(std::move(before)); + } + if (result.valid) + { + result.message = "语法检查通过"; + } + return result; +} + LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) +{ + return insertRung(logic_id, {}, true); +} + +LogicEditorResult LogicEditorService::insertRung( + const std::string &logic_id, + const std::string &reference_rung_id, + bool after) { const ControlLogic *logic = findLogic(logic_id); if (logic == nullptr) { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const std::string limit_message = rungLimitMessage( - project_service_.project(), *logic, project_service_.projectLimits()); - if (!limit_message.empty()) + if (logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) { - return failure(LogicEditorError::InvalidOperation, limit_message); + return failure( + LogicEditorError::InvalidOperation, + "梯形图行数已经达到当前上限"); } - LadderRung rung; - rung.id = makeUniqueRungId(*logic); - rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); + std::size_t position = logic->rungs.size(); + if (!reference_rung_id.empty()) + { + const std::size_t reference = rungIndex(*logic, reference_rung_id); + if (reference == logic->rungs.size()) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); + } + position = reference + (after ? 1U : 0U); + } + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); 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; }); - target->rungs.push_back(std::move(rung)); + ControlLogic *editable = editableLogic(&project, logic_id); + const std::string new_id = insertEmptyRungAt(editable, position); + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, error); + } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, target->rungs.back().id}; + return {true, LogicEditorError::None, {}, new_id}; } LogicEditorResult LogicEditorService::removeRung( @@ -1198,37 +1178,41 @@ LogicEditorResult LogicEditorService::removeRungs( } if (rung_ids.empty()) { - return failure(LogicEditorError::InvalidOperation, "请先选择要删除的梯形图网络"); + return failure(LogicEditorError::InvalidOperation, "请先选择要删除的行"); } - std::unordered_set selected_ids; + std::unordered_set unique_ids; + std::vector indices; for (const std::string &rung_id : rung_ids) { - if (!selected_ids.insert(rung_id).second) + const std::size_t index = rungIndex(*logic, rung_id); + if (!unique_ids.insert(rung_id).second) { - return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复网络"); + return failure( + LogicEditorError::InvalidOperation, + "删除列表中存在重复行"); } - if (findRung(logic_id, rung_id) == nullptr) + if (index == logic->rungs.size()) { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } + indices.push_back(index); } - if (selected_ids.empty()) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); - } + std::sort(indices.begin(), indices.end(), std::greater()); + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); 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; }); - target->rungs.erase( - std::remove_if( - target->rungs.begin(), target->rungs.end(), - [&selected_ids](const LadderRung &rung) - { - return selected_ids.find(rung.id) != selected_ids.end(); - }), - target->rungs.end()); + ControlLogic *editable = editableLogic(&project, logic_id); + for (std::size_t index : indices) + { + removeRungAt(editable, index); + } + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, error); + } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, rung_ids.front()}; } @@ -1241,184 +1225,76 @@ LogicEditorResult LogicEditorService::updateRungComment( const LadderRung *rung = findRung(logic_id, rung_id); if (rung == nullptr) { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); - } - if (containsLineBreak(comment)) - { - return failure( - LogicEditorError::InvalidOperation, - "梯形图网络注释只能使用单行文本"); + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } - if (comment.size() > ProjectLimits::kMaximumRungCommentBytes) + if (containsLineBreak(comment) + || comment.size() > ProjectLimits::kMaximumRungCommentBytes) { return failure( LogicEditorError::InvalidOperation, - "梯形图网络注释不能超过 128 个 UTF-8 字节"); + "行注释必须是最多 128 个 UTF-8 字节的单行文本"); } if (rung->comment == comment) { return {true, LogicEditorError::None, {}, rung_id}; } HistoryState before = captureState(); - LadderRung &editable = *findEditableRung( - project_service_.editProject(), logic_id, rung_id); - editable.comment = comment; + Project &project = project_service_.editProject(); + editableRung(editableLogic(&project, logic_id), rung_id)->comment = comment; recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, rung_id}; } -LogicEditorResult LogicEditorService::appendCondition( +LogicEditorResult LogicEditorService::setConditionAtColumn( const std::string &logic_id, const std::string &rung_id, + int column, const LogicNodeConfig &config, bool configured) { - const ControlLogic *logic = findLogic(logic_id); - if (logic == nullptr) - { - return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); - } - const bool create_rung = rung_id.empty(); - const LadderRung *existing_rung = create_rung - ? nullptr : findRung(logic_id, rung_id); - if (!create_rung && existing_rung == nullptr) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); - } - const std::string limit_message = create_rung - ? rungLimitMessage( - project_service_.project(), *logic, project_service_.projectLimits()) - : std::string{}; - if (!limit_message.empty()) + const LadderCell *cell = findCell(logic_id, rung_id, column); + if (cell == nullptr) { - return failure(LogicEditorError::InvalidOperation, limit_message); + return failure(LogicEditorError::CellNotFound, "未找到目标条件网格"); } if (!isConditionConfig(config)) { - return failure(LogicEditorError::InvalidNode, "梯形图条件不能使用线圈节点"); + return failure(LogicEditorError::InvalidNode, "条件区只能放置条件指令"); } - if (existing_rung != nullptr && !existing_rung->output.has_value() - && existing_rung->condition.has_value()) + LogicNode candidate{"candidate", config, configured}; + std::string error; + if (!candidate.validate(&error)) { - // 普通条件追加也优先填充删除后保留的 Gap,避免把新节点追加到网络末端 - if (const auto gap = firstGapCell(*existing_rung->condition); gap.has_value()) - { - return replaceGapColumnWithCondition( - logic_id, rung_id, gap->first, gap->second, config, configured); - } + return failure(LogicEditorError::InvalidNode, error); } - // 先生成稳定节点 ID,再把新节点接到已有表达式的串联末尾 - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - const std::string target_rung_id = create_rung - ? makeUniqueRungId(*logic) : rung_id; - ConditionExpression leaf = ConditionExpression::fromNode( - makeNode(node_id, config, configured)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (create_rung) - { - editable_logic->rungs.push_back({ - target_rung_id, - "网络 " + std::to_string(editable_logic->rungs.size() + 1U), - {}, - std::nullopt, - std::nullopt}); - } - LadderRung *rung = findEditableRung(project, logic_id, target_rung_id); - if (rung->output.has_value() && rung->condition.has_value()) - { - ConditionExpression *span = &*rung->condition; - std::vector *siblings = nullptr; - std::size_t span_index = 0U; - if (span->kind == ConditionExpressionKind::Series) - { - siblings = &span->children; - span_index = siblings->size(); - while (span_index > 0U) - { - const ConditionExpressionKind kind = - siblings->at(span_index - 1U).kind; - if (kind != ConditionExpressionKind::Wire - && kind != ConditionExpressionKind::Gap) - { - break; - } - --span_index; - } - span = span_index < siblings->size() - ? &siblings->at(span_index) : nullptr; - } - if (span == nullptr - || (span->kind != ConditionExpressionKind::Wire - && span->kind != ConditionExpressionKind::Gap)) - { - rollbackEdit(std::move(before), modified_before); - return failure( - LogicEditorError::InvalidOperation, - "输出网络的 10 列条件区已经没有可替换的横线或断路"); - } - const bool wire_span = span->kind == ConditionExpressionKind::Wire; - const int span_columns = wire_span - ? span->wire->columnSpan : span->gap->columnSpan; - const std::string span_id = span->id; - if (span_columns == 1) - { - *span = std::move(leaf); - } - else if (siblings != nullptr) - { - *span = std::move(leaf); - siblings->insert( - siblings->begin() + static_cast(span_index + 1U), - wire_span - ? ConditionExpression::fromWire(span_id, span_columns - 1) - : ConditionExpression::fromGap(span_id, span_columns - 1)); - } - else - { - const std::string container_id = makeUniqueExpressionId(*editable_logic); - ConditionExpression remaining = wire_span - ? ConditionExpression::fromWire(span_id, span_columns - 1) - : ConditionExpression::fromGap(span_id, span_columns - 1); - *span = makeContainer( - container_id, - ConditionExpressionKind::Series, - std::move(leaf), - std::move(remaining)); - } - } - else if (!rung->condition.has_value()) - { - rung->condition = std::move(leaf); - } - else if (rung->condition->kind == ConditionExpressionKind::Series) - { - rung->condition->children.push_back(std::move(leaf)); - } - else - { - rung->condition = makeContainer( - makeUniqueExpressionId(*editable_logic), - ConditionExpressionKind::Series, - std::move(*rung->condition), - std::move(leaf)); - } - std::string validation_error; - // 编辑服务修改后立即做整条网络校验;失败就恢复包括脏标记在内的完整快照 - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + ControlLogic *logic = editableLogic(&project, logic_id); + LadderRung *rung = editableRung(logic, rung_id); + LadderCell &editable_cell = rung->cells[static_cast(column)]; + const std::string node_id = makeUniqueId(*logic, nodePrefix(config)); + editable_cell.kind = LadderCellKind::Node; + editable_cell.node = LogicNode{node_id, config, configured}; + if (!logic->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } +LogicEditResult LogicEditorService::applyConditionAndAdvance( + const std::string &logic_id, + const LogicEditCursor &cursor, + const LogicNodeConfig &config, + bool configured) +{ + return applyCellAndAdvance( + logic_id, cursor, &config, configured); +} + LogicEditorResult LogicEditorService::insertConditionAtColumn( const std::string &logic_id, const std::string &rung_id, @@ -1426,196 +1302,141 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( const LogicNodeConfig &config, bool configured) { - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr) + const LadderRung *existing = findRung(logic_id, rung_id); + if (existing == nullptr || column < 0 + || column >= ProjectLimits::kMaximumConditionColumns) { - return failure( - logic == nullptr ? LogicEditorError::LogicNotFound - : LogicEditorError::RungNotFound, - logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络"); + return failure(LogicEditorError::CellNotFound, "未找到目标条件网格"); } if (!isConditionConfig(config)) { - return failure(LogicEditorError::InvalidNode, "梯形图条件不能使用输出节点"); - } - if (column < 0 || column >= ProjectLimits::kMaximumConditionColumns) - { - return failure(LogicEditorError::InvalidOperation, "条件插入列必须位于第 1~10 列"); + return failure(LogicEditorError::InvalidNode, "条件区只能放置条件指令"); } - - const int occupied_columns = existing_rung->condition.has_value() - ? expressionColumns(*existing_rung->condition) : 0; - if (column < occupied_columns) + if (existing->cells.back().kind != LadderCellKind::Gap) { - return failure(LogicEditorError::InvalidOperation, "所选网格已被条件或横线占用"); + return failure( + LogicEditorError::InvalidOperation, + "第 10 列已有内容,无法继续向右插入"); } - // 点击远端空网格时,用持久化横线填充前置空档,再放入真实条件节点 - const int leading_wire_columns = column - occupied_columns; - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression leaf = ConditionExpression::fromNode( - makeNode(node_id, config, configured)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - - if (leading_wire_columns > 0) + ControlLogic *logic = editableLogic(&project, logic_id); + LadderRung *rung = editableRung(logic, rung_id); + for (int index = ProjectLimits::kMaximumConditionColumns - 1; + index > column; + --index) { - ConditionExpression wire = ConditionExpression::fromWire( - makeUniqueWireId(*logic), leading_wire_columns); - if (!rung->condition.has_value()) - { - rung->condition = makeContainer( - makeUniqueExpressionId(*logic), - ConditionExpressionKind::Series, - std::move(wire), - std::move(leaf)); - } - else if (rung->condition->kind == ConditionExpressionKind::Series) - { - rung->condition->children.push_back(std::move(wire)); - rung->condition->children.push_back(std::move(leaf)); - } - else - { - ConditionExpression series; - series.id = makeUniqueExpressionId(*logic); - series.kind = ConditionExpressionKind::Series; - series.children.push_back(std::move(*rung->condition)); - series.children.push_back(std::move(wire)); - series.children.push_back(std::move(leaf)); - rung->condition = std::move(series); - } - } - else if (!rung->condition.has_value()) - { - rung->condition = std::move(leaf); + rung->cells[static_cast(index)] = + std::move(rung->cells[static_cast(index - 1)]); } - else if (rung->condition->kind == ConditionExpressionKind::Series) + // 插入列会让右侧网格右移,边界 10 是固定输出侧边界,不能继续右移 + for (VerticalConnection &connection : logic->verticalConnections) { - rung->condition->children.push_back(std::move(leaf)); - } - else - { - rung->condition = makeContainer( - makeUniqueExpressionId(*logic), - ConditionExpressionKind::Series, - std::move(*rung->condition), - std::move(leaf)); + if ((connection.upperRungId == rung_id + || connection.lowerRungId == rung_id) + && connection.columnBoundary >= column + && connection.columnBoundary < ProjectLimits::kMaximumConditionColumns) + { + ++connection.columnBoundary; + } } - - std::string validation_error; - // 原子提交要求候选表达式整体通过校验,否则撤销本次所有局部拼接 - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + LadderCell inserted; + inserted.id = makeUniqueId(*logic, "cell"); + inserted.kind = LadderCellKind::Node; + const std::string node_id = makeUniqueId(*logic, nodePrefix(config)); + inserted.node = LogicNode{node_id, config, configured}; + rung->cells[static_cast(column)] = std::move(inserted); + std::string error; + if (!logic->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } -LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( +LogicEditorResult LogicEditorService::appendCondition( const std::string &logic_id, const std::string &rung_id, - const std::string &branch_expression_id, - int column, const LogicNodeConfig &config, bool configured) { const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr - || !existing_rung->condition.has_value()) + if (logic == nullptr) { - return failure( - logic == nullptr ? LogicEditorError::LogicNotFound - : LogicEditorError::RungNotFound, - logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } if (!isConditionConfig(config)) { - return failure(LogicEditorError::InvalidNode, "并联空网格只能插入条件节点"); - } - - const ConditionExpression *branch = findExpression( - logic_id, rung_id, branch_expression_id); - const ConditionExpression *branch_parent = findParentExpression( - *existing_rung->condition, branch_expression_id); - if (branch == nullptr || branch_parent == nullptr - || branch_parent->kind != ConditionExpressionKind::Parallel) - { - return failure( - LogicEditorError::ExpressionNotFound, - "未找到并联分支空网格"); + return failure(LogicEditorError::InvalidNode, "条件区只能放置条件指令"); } - - const int branch_columns = expressionColumns(*branch); - const int parallel_columns = expressionColumns(*branch_parent); - if (column < branch_columns || column >= parallel_columns) + std::string target_rung_id = rung_id; + if (target_rung_id.empty()) { - return failure( - LogicEditorError::InvalidOperation, - "所选位置不是并联分支中的可用空网格"); - } - - const int leading_wire_columns = column - branch_columns; - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - const std::string wire_id = leading_wire_columns > 0 - ? makeUniqueWireId(*logic) : std::string{}; - const std::string series_id = makeUniqueExpressionId(*logic); - ConditionExpression leaf = ConditionExpression::fromNode( - makeNode(node_id, config, configured)); - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung( - project, logic_id, rung_id); - ConditionExpression *editable_branch = findConditionExpression( - *rung->condition, branch_expression_id); - - if (editable_branch->kind == ConditionExpressionKind::Series) - { - if (leading_wire_columns > 0) + if (logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) + { + return failure( + LogicEditorError::InvalidOperation, + "梯形图行数已经达到当前上限"); + } + // 新建首行和放置首个节点必须共用一条历史记录 + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + ControlLogic *editable = editableLogic(&project, logic_id); + target_rung_id = insertEmptyRungAt(editable, editable->rungs.size()); + LadderRung *created = editableRung(editable, target_rung_id); + const auto empty = std::find_if( + created->cells.begin(), created->cells.end(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Gap; + }); + if (empty == created->cells.end()) { - editable_branch->children.push_back(ConditionExpression::fromWire( - wire_id, leading_wire_columns)); + rollbackEdit(std::move(before), modified_before); + return failure( + LogicEditorError::InvalidOperation, + "条件区 10 列已经占满"); } - editable_branch->children.push_back(std::move(leaf)); - } - else - { - ConditionExpression series; - series.id = series_id; - series.kind = ConditionExpressionKind::Series; - series.children.push_back(std::move(*editable_branch)); - if (leading_wire_columns > 0) + const std::string node_id = makeUniqueId(*editable, nodePrefix(config)); + LadderCell &cell = created->cells[ + static_cast(std::distance(created->cells.begin(), empty))]; + cell.kind = LadderCellKind::Node; + cell.node = LogicNode{node_id, config, configured}; + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { - series.children.push_back(ConditionExpression::fromWire( - wire_id, leading_wire_columns)); + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, error); } - series.children.push_back(std::move(leaf)); - *editable_branch = std::move(series); + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; } - - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + const LadderRung *rung = findRung(logic_id, target_rung_id); + if (rung == nullptr) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; + const auto empty = std::find_if( + rung->cells.cbegin(), rung->cells.cend(), + [](const LadderCell &cell) { return cell.kind == LadderCellKind::Gap; }); + if (empty == rung->cells.cend()) + { + return failure(LogicEditorError::InvalidOperation, "条件区 10 列已经占满"); + } + return setConditionAtColumn( + logic_id, + target_rung_id, + static_cast(std::distance(rung->cells.cbegin(), empty)), + config, + configured); } LogicEditorResult LogicEditorService::appendWire( @@ -1628,1078 +1449,725 @@ LogicEditorResult LogicEditorService::appendWire( { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - WireSegment wire{column_span}; - std::string error; - if (!wire.validate(&error)) - { - return failure(LogicEditorError::InvalidOperation, error); - } - const LadderRung *existing_rung = rung_id.empty() - ? nullptr : findRung(logic_id, rung_id); - if (!rung_id.empty() && existing_rung == nullptr) + if (column_span <= 0 + || column_span > ProjectLimits::kMaximumConditionColumns) { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + return failure(LogicEditorError::InvalidOperation, "横线参数无效"); } - if (existing_rung != nullptr && column_span == 1 - && existing_rung->condition.has_value()) + if (rung_id.empty()) { - // 删除后的 Gap 仍保留原网格位置;无选中目标追加时优先填回第一个 Gap - if (const auto gap = firstGapCell(*existing_rung->condition); gap.has_value()) + if (logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) { - return replaceGapColumnWithWire( - logic_id, rung_id, gap->first, gap->second); + return failure( + LogicEditorError::InvalidOperation, + "梯形图行数已经达到当前上限"); + } + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + ControlLogic *editable = editableLogic(&project, logic_id); + const std::string new_rung_id = insertEmptyRungAt( + editable, editable->rungs.size()); + LadderRung *rung = editableRung(editable, new_rung_id); + const int first = ProjectLimits::kMaximumConditionColumns - column_span; + for (int column = first; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung->cells[static_cast(column)].kind = + LadderCellKind::Wire; + } + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, error); } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, new_rung_id}; } - bool create_rung = rung_id.empty(); - if (!create_rung && column_span == 1 && existing_rung->condition.has_value() - && expressionColumns(*existing_rung->condition) - >= ProjectLimits::kMaximumConditionColumns) + const LadderRung *rung = findRung(logic_id, rung_id); + if (rung == nullptr) { - // 无选中目标的连续追加达到十列后,原子切换到下一个网络 - create_rung = true; + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } - const std::string limit_message = create_rung - ? rungLimitMessage( - project_service_.project(), *logic, project_service_.projectLimits()) - : std::string{}; - if (!limit_message.empty()) + const int first = ProjectLimits::kMaximumConditionColumns - column_span; + return setHorizontalWireRange(logic_id, rung_id, first, + ProjectLimits::kMaximumConditionColumns - 1, + true); +} + +LogicEditResult LogicEditorService::applyWireAndAdvance( + const std::string &logic_id, + const LogicEditCursor &cursor) +{ + return applyCellAndAdvance(logic_id, cursor, nullptr, false); +} + +LogicEditorResult LogicEditorService::setHorizontalWireRange( + const std::string &logic_id, + const std::string &rung_id, + int first_column, + int last_column, + bool connected) +{ + if (first_column > last_column) { - return failure(LogicEditorError::InvalidOperation, limit_message); + std::swap(first_column, last_column); } - const std::string target_rung_id = create_rung - ? makeUniqueRungId(*logic) : rung_id; - const std::string wire_id = makeUniqueWireId(*logic); - ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (create_rung) - { - editable_logic->rungs.push_back({ - target_rung_id, - "网络 " + std::to_string(editable_logic->rungs.size() + 1U), - {}, - std::nullopt, - std::nullopt}); + if (findRung(logic_id, rung_id) == nullptr || first_column < 0 + || last_column >= ProjectLimits::kMaximumConditionColumns) + { + return failure(LogicEditorError::CellNotFound, "横线范围超出条件网格"); + } + std::vector> cells; + for (int column = first_column; column <= last_column; ++column) + { + cells.emplace_back(rung_id, column); } - LadderRung *rung = findEditableRung(project, logic_id, target_rung_id); - if (!rung->condition.has_value()) + return setWireCells(logic_id, cells, connected); +} + +LogicEditorResult LogicEditorService::setWireCells( + const std::string &logic_id, + const std::vector> &cells, + bool connected) +{ + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) { - rung->condition = std::move(leaf); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - else if (rung->condition->kind == ConditionExpressionKind::Series) + if (cells.empty()) { - rung->condition->children.push_back(std::move(leaf)); + return failure(LogicEditorError::InvalidOperation, "没有需要修改的横线网格"); } - else + std::unordered_set positions; + for (const auto &position : cells) + { + const std::string key = position.first + "\n" + std::to_string(position.second); + if (!positions.insert(key).second) + { + return failure(LogicEditorError::InvalidOperation, "横线范围包含重复网格"); + } + if (findCell(logic_id, position.first, position.second) == nullptr) + { + return failure(LogicEditorError::CellNotFound, "横线范围包含无效网格"); + } + } + + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + ControlLogic *editable = editableLogic(&project, logic_id); + for (const auto &position : cells) { - rung->condition = makeContainer( - makeUniqueExpressionId(*logic), - ConditionExpressionKind::Series, - std::move(*rung->condition), - std::move(leaf)); + LadderCell &cell = editableRung(editable, position.first) + ->cells[static_cast(position.second)]; + if (cell.kind == LadderCellKind::Node) + { + continue; + } + cell.kind = connected ? LadderCellKind::Wire : LadderCellKind::Gap; + cell.node.reset(); } - std::string validation_error; - if (!rung->validate(&validation_error)) + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, wire_id}; + return {true, LogicEditorError::None, {}, cells.front().first}; } -LogicEditorResult LogicEditorService::insertConditionAfter( +LogicEditorResult LogicEditorService::clearCells( const std::string &logic_id, - const std::string &rung_id, - const std::string &target_node_id, - const LogicNodeConfig &config) + const std::vector> &cells) { - const ControlLogic *logic = findLogic(logic_id); - if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) + if (findLogic(logic_id) == nullptr) { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (!isConditionConfig(config) - || existing_rung == nullptr - || !existing_rung->condition.has_value() - || findConditionNode(*existing_rung->condition, target_node_id) == nullptr) + if (cells.empty()) { - return failure(LogicEditorError::NodeNotFound, "未找到串联插入目标"); + return failure(LogicEditorError::InvalidOperation, "没有需要清空的网格"); } - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); - ConditionExpression *parent = findParentExpression(*rung->condition, target_node_id); - if (parent != nullptr && parent->kind == ConditionExpressionKind::Series) - { - const auto target_iterator = std::find_if( - parent->children.begin(), parent->children.end(), - [&target_node_id](const ConditionExpression &child) - { - return child.id == target_node_id; - }); - const auto insertion_position = target_iterator + 1; - if (insertion_position != parent->children.end() - && (insertion_position->kind == ConditionExpressionKind::Wire - || insertion_position->kind == ConditionExpressionKind::Gap)) - { - int &span = insertion_position->kind == ConditionExpressionKind::Wire - ? insertion_position->wire->columnSpan - : insertion_position->gap->columnSpan; - // 连续插入优先消费后方显式横线或断路的一格 - if (span == 1) - { - *insertion_position = std::move(leaf); - } - else - { - --span; - parent->children.insert(insertion_position, std::move(leaf)); - } - } - else + for (const auto &position : cells) + { + if (findCell(logic_id, position.first, position.second) == nullptr) { - parent->children.insert(insertion_position, std::move(leaf)); + return failure(LogicEditorError::CellNotFound, "清空范围包含无效网格"); } } - else + HistoryState before = captureState(); + Project &project = project_service_.editProject(); + ControlLogic *logic = editableLogic(&project, logic_id); + for (const auto &position : cells) { - ConditionExpression original = std::move(*target); - *target = makeContainer( - makeUniqueExpressionId(*editable_logic), - ConditionExpressionKind::Series, - std::move(original), - std::move(leaf)); + LadderCell &cell = editableRung(logic, position.first) + ->cells[static_cast(position.second)]; + cell.kind = LadderCellKind::Gap; + cell.node.reset(); } - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, cells.front().first}; +} + +LogicEditorResult LogicEditorService::setVerticalConnection( + const std::string &logic_id, + const std::string &upper_rung_id, + const std::string &lower_rung_id, + int column_boundary, + bool connected) +{ + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; + const std::size_t upper = rungIndex(*logic, upper_rung_id); + const std::size_t lower = rungIndex(*logic, lower_rung_id); + if (upper == logic->rungs.size() || lower != upper + 1U) + { + return failure( + LogicEditorError::InvalidOperation, + "竖线只能连接相邻的上下两行"); + } + return setVerticalConnectionRange( + logic_id, + upper_rung_id, + lower_rung_id, + column_boundary, + connected); } -LogicEditorResult LogicEditorService::insertWireAfter( +LogicEditorResult LogicEditorService::setVerticalConnectionRange( const std::string &logic_id, - const std::string &rung_id, - const std::string &target_expression_id, - int column_span) + const std::string &first_rung_id, + const std::string &last_rung_id, + int column_boundary, + bool connected) { const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - const ConditionExpression *target = findExpression( - logic_id, rung_id, target_expression_id); - WireSegment wire{column_span}; - std::string error; - if (logic == nullptr || existing_rung == nullptr || target == nullptr) + if (logic == nullptr) { - return failure(LogicEditorError::ExpressionNotFound, "未找到横线插入位置"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - if ((target->kind != ConditionExpressionKind::Node - && target->kind != ConditionExpressionKind::Wire) - || !wire.validate(&error)) + std::size_t first = rungIndex(*logic, first_rung_id); + std::size_t last = rungIndex(*logic, last_rung_id); + if (first == logic->rungs.size() || last == logic->rungs.size() + || first == last || column_boundary < 0 + || column_boundary > ProjectLimits::kMaximumConditionColumns) { return failure( LogicEditorError::InvalidOperation, - error.empty() ? "只能在触点或横线后插入横线" : error); + "竖线范围必须跨越至少两行且位于 0~10 列边界"); + } + if (first > last) + { + std::swap(first, last); } - const std::string wire_id = makeUniqueWireId(*logic); - ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); + HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung( - project, logic_id, rung_id); - ConditionExpression *editable_target = findConditionExpression( - *rung->condition, target_expression_id); - ConditionExpression *parent = findParentExpression( - *rung->condition, target_expression_id); - if (parent != nullptr && parent->kind == ConditionExpressionKind::Series) - { - const auto target_iterator = std::find_if( - parent->children.begin(), parent->children.end(), - [&target_expression_id](const ConditionExpression &child) + ControlLogic *editable = editableLogic(&project, logic_id); + for (std::size_t row = first; row < last; ++row) + { + const std::string upper_id = editable->rungs[row].id; + const std::string lower_id = editable->rungs[row + 1U].id; + const auto found = std::find_if( + editable->verticalConnections.begin(), + editable->verticalConnections.end(), + [&upper_id, &lower_id, column_boundary]( + const VerticalConnection &connection) { - return child.id == target_expression_id; + return connectionMatches( + connection, upper_id, lower_id, column_boundary); }); - const auto insertion_position = target_iterator + 1; - if (insertion_position != parent->children.end() - && insertion_position->kind == ConditionExpressionKind::Gap) + if (connected && found == editable->verticalConnections.end()) { - // 连续添加横线时优先消耗目标后面的 Gap,保持网络总列数不变 - if (insertion_position->gap->columnSpan == 1) - { - *insertion_position = std::move(leaf); - } - else - { - --insertion_position->gap->columnSpan; - parent->children.insert(insertion_position, std::move(leaf)); - } + editable->verticalConnections.push_back({ + makeUniqueId(*editable, "vertical"), + upper_id, + lower_id, + column_boundary}); } - else + else if (!connected && found != editable->verticalConnections.end()) { - parent->children.insert(insertion_position, std::move(leaf)); + editable->verticalConnections.erase(found); } } - else - { - ConditionExpression original = std::move(*editable_target); - *editable_target = makeContainer( - makeUniqueExpressionId(*editable_logic), - ConditionExpressionKind::Series, - std::move(original), - std::move(leaf)); - } - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, wire_id}; + return {true, LogicEditorError::None, {}, first_rung_id}; } -LogicEditorResult LogicEditorService::replaceWireWithCondition( +LogicEditorResult LogicEditorService::removeVerticalConnections( const std::string &logic_id, - const std::string &rung_id, - const std::string &wire_expression_id, - const LogicNodeConfig &config) + const std::vector &connection_ids) { const ControlLogic *logic = findLogic(logic_id); - const ConditionExpression *wire = findExpression( - logic_id, rung_id, wire_expression_id); - if (logic == nullptr || wire == nullptr - || wire->kind != ConditionExpressionKind::Wire) + if (logic == nullptr) { - return failure(LogicEditorError::ExpressionNotFound, "未找到要替换的横线"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - if (!isConditionConfig(config)) + if (connection_ids.empty()) { - return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点"); + return failure(LogicEditorError::InvalidOperation, "请先选择要删除的竖线"); } - if (wire->wire->columnSpan > 1) + std::unordered_set ids; + for (const std::string &id : connection_ids) { - return replaceWireColumnWithCondition( - logic_id, rung_id, wire_expression_id, 0, config); + if (!ids.insert(id).second + || findVerticalConnection(*logic, id) == nullptr) + { + return failure( + LogicEditorError::ConnectionNotFound, + "竖线删除列表包含重复或不存在的对象"); + } } - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); - ConditionExpression *editable = findConditionExpression( - *rung->condition, wire_expression_id); - *editable = ConditionExpression::fromNode(makeNode(node_id, config)); - std::string validation_error; - if (!rung->validate(&validation_error)) - { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidNode, validation_error); - } + Project &project = project_service_.editProject(); + ControlLogic *editable = editableLogic(&project, logic_id); + editable->verticalConnections.erase( + std::remove_if( + editable->verticalConnections.begin(), + editable->verticalConnections.end(), + [&ids](const VerticalConnection &connection) + { + return ids.count(connection.id) != 0U; + }), + editable->verticalConnections.end()); recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; + return {true, LogicEditorError::None, {}, connection_ids.front()}; } -LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( +LogicEditorResult LogicEditorService::deleteSelection( const std::string &logic_id, - const std::string &rung_id, - const std::string &wire_expression_id, - int column_offset, - const LogicNodeConfig &config, - bool configured) + const LogicSelectionDeleteRequest &selection) { const ControlLogic *logic = findLogic(logic_id); - const ConditionExpression *wire = findExpression( - logic_id, rung_id, wire_expression_id); - if (logic == nullptr || wire == nullptr - || wire->kind != ConditionExpressionKind::Wire) - { - return failure(LogicEditorError::ExpressionNotFound, "未找到要替换的横线"); - } - if (!isConditionConfig(config)) + if (logic == nullptr) { - return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - if (column_offset < 0 || column_offset >= wire->wire->columnSpan) + if (selection.cells.empty() && selection.outputRungIds.empty() + && selection.verticalConnectionIds.empty()) { - return failure(LogicEditorError::InvalidOperation, "横线网格偏移超出有效范围"); + return failure(LogicEditorError::InvalidOperation, "没有可删除的选中对象"); } - if (wire->wire->columnSpan == 1) + + std::unordered_set cell_positions; + for (const auto &position : selection.cells) { - return replaceWireWithCondition( - logic_id, rung_id, wire_expression_id, config); + const std::string key = position.first + "\n" + + std::to_string(position.second); + const LadderCell *cell = findCell( + logic_id, position.first, position.second); + if (!cell_positions.insert(key).second || cell == nullptr) + { + return failure( + LogicEditorError::CellNotFound, + "删除列表包含重复或不存在的网格"); + } + if (cell->kind == LadderCellKind::Gap) + { + return failure( + LogicEditorError::InvalidOperation, + "删除列表包含空白网格"); + } } - const int leading_columns = column_offset; - const int trailing_columns = wire->wire->columnSpan - column_offset - 1; - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression replacement; - replacement.id = makeUniqueExpressionId(*logic); - replacement.kind = ConditionExpressionKind::Series; - if (leading_columns > 0) + std::unordered_set output_rung_ids; + for (const std::string &rung_id : selection.outputRungIds) { - replacement.children.push_back(ConditionExpression::fromWire( - wire_expression_id, leading_columns)); + const LadderRung *rung = findRung(logic_id, rung_id); + if (!output_rung_ids.insert(rung_id).second || rung == nullptr + || !rung->output.has_value()) + { + return failure( + LogicEditorError::NodeNotFound, + "删除列表包含重复或不存在的输出指令"); + } } - replacement.children.push_back( - ConditionExpression::fromNode(makeNode(node_id, config, configured))); - if (trailing_columns > 0) + + std::unordered_set connection_ids; + for (const std::string &connection_id + : selection.verticalConnectionIds) { - replacement.children.push_back(ConditionExpression::fromWire( - leading_columns > 0 ? makeUniqueWireId(*logic) : wire_expression_id, - trailing_columns)); + if (!connection_ids.insert(connection_id).second + || findVerticalConnection(*logic, connection_id) == nullptr) + { + return failure( + LogicEditorError::ConnectionNotFound, + "删除列表包含重复或不存在的竖线"); + } } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - ConditionExpression *editable = findConditionExpression( - *rung->condition, wire_expression_id); - *editable = std::move(replacement); - normalizeConditionExpression(&rung->condition); - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + ControlLogic *editable = editableLogic(&project, logic_id); + for (const auto &position : selection.cells) + { + LadderCell &cell = editableRung(editable, position.first) + ->cells[static_cast(position.second)]; + cell.kind = LadderCellKind::Gap; + cell.node.reset(); + } + for (const std::string &rung_id : selection.outputRungIds) + { + editableRung(editable, rung_id)->output.reset(); + } + editable->verticalConnections.erase( + std::remove_if( + editable->verticalConnections.begin(), + editable->verticalConnections.end(), + [&connection_ids](const VerticalConnection &connection) + { + return connection_ids.count(connection.id) != 0U; + }), + editable->verticalConnections.end()); + + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidNode, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; + return {true, LogicEditorError::None, {}, logic_id}; } -LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( +LogicEditorResult LogicEditorService::addParallelBranch( const std::string &logic_id, const std::string &rung_id, - const std::string &gap_expression_id, - int column_offset, + const std::vector &selected_node_ids, const LogicNodeConfig &config, bool configured) { const ControlLogic *logic = findLogic(logic_id); - const ConditionExpression *gap = findExpression( - logic_id, rung_id, gap_expression_id); - if (logic == nullptr || gap == nullptr - || gap->kind != ConditionExpressionKind::Gap) - { - return failure(LogicEditorError::ExpressionNotFound, "未找到要填充的断路网格"); - } - if (!isConditionConfig(config)) + const LadderRung *rung = findRung(logic_id, rung_id); + if (logic == nullptr || rung == nullptr) { - return failure(LogicEditorError::InvalidNode, "断路网格只能填入条件节点"); + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } - if (column_offset < 0 || column_offset >= gap->gap->columnSpan) + if (!isConditionConfig(config) + || !areConditionNodesContiguous(logic_id, rung_id, selected_node_ids)) { - return failure(LogicEditorError::InvalidOperation, "断路网格偏移超出有效范围"); + return failure( + LogicEditorError::InvalidOperation, + "请先选择同一行中连续的条件节点"); } - - const int leading_columns = column_offset; - const int trailing_columns = gap->gap->columnSpan - column_offset - 1; - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression replacement; - if (leading_columns == 0 && trailing_columns == 0) + if (logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) { - replacement = ConditionExpression::fromNode( - makeNode(node_id, config, configured)); + return failure(LogicEditorError::InvalidOperation, "梯形图行数已经达到上限"); } - else + int first_column = ProjectLimits::kMaximumConditionColumns; + int last_column = -1; + for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { - replacement.id = makeUniqueExpressionId(*logic); - replacement.kind = ConditionExpressionKind::Series; - if (leading_columns > 0) - { - replacement.children.push_back(ConditionExpression::fromGap( - gap_expression_id, leading_columns)); - } - replacement.children.push_back( - ConditionExpression::fromNode(makeNode(node_id, config, configured))); - if (trailing_columns > 0) + const LadderCell &cell = rung->cells[static_cast(column)]; + if (cell.node.has_value() + && std::find( + selected_node_ids.cbegin(), selected_node_ids.cend(), + cell.node->id) != selected_node_ids.cend()) { - replacement.children.push_back(ConditionExpression::fromGap( - leading_columns > 0 ? makeUniqueGapId(*logic) : gap_expression_id, - trailing_columns)); + first_column = std::min(first_column, column); + last_column = std::max(last_column, column); } } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); - LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); - ConditionExpression *editable = findConditionExpression( - *rung->condition, gap_expression_id); - *editable = std::move(replacement); - normalizeConditionExpression(&rung->condition); - std::string validation_error; - if (!rung->validate(&validation_error)) + Project &project = project_service_.editProject(); + ControlLogic *editable = editableLogic(&project, logic_id); + const std::size_t source_index = rungIndex(*editable, rung_id); + const std::string new_rung_id = insertEmptyRungAt(editable, source_index + 1U); + LadderRung *branch = editableRung(editable, new_rung_id); + const std::string node_id = makeUniqueId(*editable, nodePrefix(config)); + branch->cells[static_cast(first_column)].kind = + LadderCellKind::Node; + branch->cells[static_cast(first_column)].node = + LogicNode{node_id, config, configured}; + for (int column = first_column + 1; column <= last_column; ++column) + { + branch->cells[static_cast(column)].kind = + LadderCellKind::Wire; + } + const auto ensure_branch_edge = [editable, &rung_id, &new_rung_id]( + int boundary) + { + const bool exists = std::any_of( + editable->verticalConnections.cbegin(), + editable->verticalConnections.cend(), + [&rung_id, &new_rung_id, boundary]( + const VerticalConnection &connection) + { + return connectionMatches( + connection, rung_id, new_rung_id, boundary); + }); + if (!exists) + { + editable->verticalConnections.push_back({ + makeUniqueId(*editable, "vertical"), + rung_id, + new_rung_id, + boundary}); + } + }; + ensure_branch_edge(first_column); + ensure_branch_edge(last_column + 1); + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidNode, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } -LogicEditorResult LogicEditorService::replaceGapColumnWithWire( +LogicEditorResult LogicEditorService::addParallelToWholeCondition( const std::string &logic_id, const std::string &rung_id, - const std::string &gap_expression_id, - int column_offset) + const LogicNodeConfig &config, + bool configured) { - const ControlLogic *logic = findLogic(logic_id); - const ConditionExpression *gap = findExpression( - logic_id, rung_id, gap_expression_id); - if (logic == nullptr || gap == nullptr - || gap->kind != ConditionExpressionKind::Gap) - { - return failure(LogicEditorError::ExpressionNotFound, "未找到要修复的断路网格"); - } - if (column_offset < 0 || column_offset >= gap->gap->columnSpan) - { - return failure(LogicEditorError::InvalidOperation, "断路网格偏移超出有效范围"); - } - const int leading_columns = column_offset; - const int trailing_columns = gap->gap->columnSpan - column_offset - 1; - const std::string wire_id = makeUniqueWireId(*logic); - ConditionExpression replacement; - if (leading_columns == 0 && trailing_columns == 0) + const LadderRung *rung = findRung(logic_id, rung_id); + if (rung == nullptr) { - replacement = ConditionExpression::fromWire(wire_id, 1); + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } - else + std::vector node_ids; + for (const LadderCell &cell : rung->cells) { - replacement.id = makeUniqueExpressionId(*logic); - replacement.kind = ConditionExpressionKind::Series; - if (leading_columns > 0) - { - replacement.children.push_back(ConditionExpression::fromGap( - gap_expression_id, leading_columns)); - } - replacement.children.push_back( - ConditionExpression::fromWire(wire_id, 1)); - if (trailing_columns > 0) + if (cell.node.has_value()) { - replacement.children.push_back(ConditionExpression::fromGap( - leading_columns > 0 ? makeUniqueGapId(*logic) : gap_expression_id, - trailing_columns)); + node_ids.push_back(cell.node->id); } } - - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); - ConditionExpression *editable = findConditionExpression( - *rung->condition, gap_expression_id); - *editable = std::move(replacement); - normalizeConditionExpression(&rung->condition); - std::string validation_error; - if (!rung->validate(&validation_error)) + if (node_ids.empty()) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure( + LogicEditorError::InvalidOperation, + "当前行还没有可并联的条件节点"); } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, wire_id}; + return addParallelBranch( + logic_id, rung_id, node_ids, config, configured); } -LogicEditorResult LogicEditorService::addParallelBranch( +LogicEditorResult LogicEditorService::setOutput( const std::string &logic_id, const std::string &rung_id, - const std::vector &selected_node_ids, const LogicNodeConfig &config, bool configured) { - const ControlLogic *logic = findLogic(logic_id); - if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); - } - if (!isConditionConfig(config) || selected_node_ids.empty()) + const ControlLogic *existing_logic = findLogic(logic_id); + if (existing_logic == nullptr) { - return failure(LogicEditorError::InvalidOperation, "建立并联支路前必须选择节点"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (!existing_rung->condition.has_value()) + if (!isOutputConfig(config)) { - return failure(LogicEditorError::InvalidOperation, "梯形图网络尚无条件节点"); + return failure(LogicEditorError::InvalidNode, "输出槽只能放置输出指令"); } - NodeIdSet selected_ids(selected_node_ids.cbegin(), selected_node_ids.cend()); - if (selected_ids.size() != selected_node_ids.size()) + if (rung_id.empty()) { - return failure( - LogicEditorError::InvalidOperation, - "并联选择中存在重复节点"); - } - const bool all_conditions = std::all_of( - selected_ids.cbegin(), selected_ids.cend(), - [&existing_rung](const std::string &node_id) + if (existing_logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) { - return findConditionNode(*existing_rung->condition, node_id) != nullptr; - }); - if (!all_conditions) - { - return failure( - LogicEditorError::NodeNotFound, - "并联选择中包含未知节点"); + return failure( + LogicEditorError::InvalidOperation, + "梯形图行数已经达到当前上限"); + } + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + ControlLogic *logic = editableLogic(&project, logic_id); + const std::string new_rung_id = insertEmptyRungAt( + logic, logic->rungs.size()); + LadderRung *rung = editableRung(logic, new_rung_id); + const std::string node_id = makeUniqueId(*logic, nodePrefix(config)); + rung->output = LogicNode{node_id, config, configured}; + std::string error; + if (!logic->validateStructure(project_service_.projectLimits(), &error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidNode, error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; } - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression leaf = ConditionExpression::fromNode( - makeNode(node_id, config, configured)); - const std::string parallel_id = makeUniqueExpressionId(*logic); - std::string series_id = parallel_id + "-range"; - while (findExpression(logic_id, rung_id, series_id) != nullptr) + if (findRung(logic_id, rung_id) == nullptr) { - series_id += "-range"; + return failure(LogicEditorError::RungNotFound, "未找到梯形图行"); } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - if (!addParallelForSelection( - &*rung->condition, - selected_ids, - &leaf, - parallel_id, - series_id)) - { - rollbackEdit(std::move(before), modified_before); - return failure( - LogicEditorError::InvalidOperation, - "并联选择必须是一个连续的逻辑范围"); - } - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + ControlLogic *logic = editableLogic(&project, logic_id); + LadderRung *rung = editableRung(logic, rung_id); + const std::string node_id = makeUniqueId(*logic, nodePrefix(config)); + rung->output = LogicNode{node_id, config, configured}; + std::string error; + if (!logic->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidNode, error); } recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } -LogicEditorResult LogicEditorService::addParallelToWholeCondition( +LogicEditResult LogicEditorService::applyOutputAndAdvance( const std::string &logic_id, - const std::string &rung_id, + const LogicEditCursor &cursor, const LogicNodeConfig &config, bool configured) { - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr - || !existing_rung->condition.has_value()) - { - return failure( - LogicEditorError::RungNotFound, - "OR/ORI 前必须先有一段条件"); - } - if (!isConditionConfig(config)) - { - return failure(LogicEditorError::InvalidNode, "并联支路只能使用条件节点"); - } - - const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); - ConditionExpression branch = ConditionExpression::fromNode( - makeNode(node_id, config, configured)); - const std::string parallel_id = makeUniqueExpressionId(*logic); - std::string branch_series_id = parallel_id + "-branch"; - while (findExpression(logic_id, rung_id, branch_series_id) != nullptr) - { - branch_series_id += "-branch"; - } - const int original_columns = expressionColumns(*existing_rung->condition); - const std::string padding_wire_id = original_columns > 1 - ? makeUniqueWireId(*logic) : std::string{}; - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - ConditionExpression original = std::move(*rung->condition); - if (original_columns > 1) - { - ConditionExpression padded; - padded.id = branch_series_id; - padded.kind = ConditionExpressionKind::Series; - padded.children.push_back(std::move(branch)); - padded.children.push_back(ConditionExpression::fromWire( - padding_wire_id, original_columns - 1)); - branch = std::move(padded); - } - rung->condition = makeContainer( - parallel_id, - ConditionExpressionKind::Parallel, - std::move(original), - std::move(branch)); - - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) - { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); - } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; -} - -LogicEditorResult LogicEditorService::addParallelWireBranch( - const std::string &logic_id, - const std::string &rung_id, - const std::vector &selected_expression_ids) -{ - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr - || !existing_rung->condition.has_value()) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); - } - if (selected_expression_ids.empty()) - { - return failure( - LogicEditorError::InvalidOperation, - "建立竖线连接前必须选择连续的触点或横线"); - } - NodeIdSet selected_ids( - selected_expression_ids.cbegin(), selected_expression_ids.cend()); - if (selected_ids.size() != selected_expression_ids.size()) - { - return failure(LogicEditorError::InvalidOperation, "并联选择中存在重复对象"); - } - const NodeIdSet available_ids = conditionLeafIds(*existing_rung->condition); - if (!isSubset(selected_ids, available_ids)) - { - return failure(LogicEditorError::ExpressionNotFound, "并联选择中包含未知对象"); - } - const std::optional column_span = selectedExpressionColumns( - *existing_rung->condition, selected_ids); - if (!column_span.has_value() - || *column_span > WireSegment::kMaximumColumnSpan) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线连接只能围绕同一支路中的连续逻辑范围"); - } - - const std::string wire_id = makeUniqueWireId(*logic); - ConditionExpression wire = ConditionExpression::fromWire( - wire_id, *column_span); - const std::string parallel_id = makeUniqueExpressionId(*logic); - std::string series_id = parallel_id + "-range"; - while (findExpression(logic_id, rung_id, series_id) != nullptr) - { - series_id += "-range"; - } - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); - if (!addParallelForSelection( - &*rung->condition, - selected_ids, - &wire, - parallel_id, - series_id)) + const ControlLogic *existing_logic = findLogic(logic_id); + if (existing_logic == nullptr) { - rollbackEdit(std::move(before), modified_before); - return failure( - LogicEditorError::InvalidOperation, - "竖线连接只能围绕同一支路中的连续逻辑范围"); + return {failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"), {}}; } - std::string validation_error; - if (!rung->validate(&validation_error)) + if (!isOutputConfig(config)) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return {failure(LogicEditorError::InvalidNode, + "输出槽只能放置输出指令"), {}}; } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, wire_id}; -} - -LogicEditorResult LogicEditorService::addParallelWireBranchAtCells( - const std::string &logic_id, - const std::string &rung_id, - const std::vector> &selected_wire_cells) -{ - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr - || !existing_rung->condition.has_value()) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); - } - if (selected_wire_cells.empty()) + LogicNode candidate{"candidate", config, configured}; + std::string error; + if (!candidate.validate(&error)) { - return failure( - LogicEditorError::InvalidOperation, - "建立竖线连接前必须选择横线网格"); + return {failure(LogicEditorError::InvalidNode, error), {}}; } - struct SelectedWireCell - { - std::string wire_id; - int offset = 0; - int absolute_column = 0; - }; - std::vector cells; - cells.reserve(selected_wire_cells.size()); - for (const auto &cell : selected_wire_cells) + std::string target_rung_id = cursor.rungId; + std::size_t target_index = existing_logic->rungs.size(); + bool create_output_rung = target_rung_id.empty(); + if (create_output_rung) { - if (cell.first.empty()) + if (!existing_logic->rungs.empty()) { - return failure( - LogicEditorError::ExpressionNotFound, - "未找到选中的横线"); - } - const ConditionExpression *wire = findExpression( - logic_id, rung_id, cell.first); - if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire - || !wire->wire.has_value() - || cell.second < 0 || cell.second >= wire->wire->columnSpan) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线网格列偏移超出横线范围"); + return {failure(LogicEditorError::RungNotFound, + "请先选择输出所在行"), {}}; } - cells.push_back({cell.first, cell.second, 0}); } - - const bool same_wire = std::all_of( - cells.cbegin(), cells.cend(), - [&cells](const SelectedWireCell &cell) - { - return cell.wire_id == cells.front().wire_id; - }); - const std::string wire_expression_id = cells.front().wire_id; - const ConditionExpression *selected_wire = findExpression( - logic_id, rung_id, wire_expression_id); - if (selected_wire == nullptr || !selected_wire->wire.has_value()) - { - return failure(LogicEditorError::ExpressionNotFound, "未找到选中的横线"); - } - int leading_columns = 0; - int trailing_columns = 0; - std::string selected_series_id; - std::size_t selected_first_child = 0U; - std::size_t selected_last_child = 0U; - if (same_wire) + else { - std::vector offsets; - offsets.reserve(cells.size()); - for (const SelectedWireCell &cell : cells) + target_index = rungIndex(*existing_logic, target_rung_id); + if (target_index == existing_logic->rungs.size()) { - offsets.push_back(cell.offset); + return {failure(LogicEditorError::RungNotFound, + "未找到梯形图行"), {}}; } - std::sort(offsets.begin(), offsets.end()); - if (std::adjacent_find(offsets.cbegin(), offsets.cend()) - != offsets.cend() - || offsets.back() - offsets.front() + 1 - != static_cast(offsets.size())) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线网格必须是连续列"); - } - for (SelectedWireCell &cell : cells) - { - cell.absolute_column = cell.offset; - } - leading_columns = offsets.front(); - trailing_columns = selected_wire->wire->columnSpan - - offsets.front() - static_cast(offsets.size()); - std::sort( - cells.begin(), cells.end(), - [](const SelectedWireCell &left, const SelectedWireCell &right) - { - return left.absolute_column < right.absolute_column; - }); } - else + + std::size_t group_end = target_index; + if (!create_output_rung) { - const ConditionExpression *source_series = nullptr; - for (SelectedWireCell &cell : cells) + while (group_end + 1U < existing_logic->rungs.size()) { - WireCellLocation location; - if (!findWireCellLocation( - *existing_rung->condition, cell.wire_id, &location) - || location.series == nullptr) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线网格必须位于同一条连续串联路径"); - } - if (source_series == nullptr) - { - source_series = location.series; - } - else if (source_series != location.series) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线网格必须位于同一条连续串联路径"); - } - cell.absolute_column = location.child_start + cell.offset; - } - std::sort( - cells.begin(), cells.end(), - [](const SelectedWireCell &left, const SelectedWireCell &right) - { - return left.absolute_column < right.absolute_column; - }); - if (std::adjacent_find( - cells.cbegin(), cells.cend(), - [](const SelectedWireCell &left, const SelectedWireCell &right) + const std::string &upper_id = existing_logic->rungs[group_end].id; + const std::string &lower_id = existing_logic->rungs[group_end + 1U].id; + const bool connected = std::any_of( + existing_logic->verticalConnections.cbegin(), + existing_logic->verticalConnections.cend(), + [&upper_id, &lower_id](const VerticalConnection &connection) { - return left.absolute_column == right.absolute_column; - }) != cells.cend() - || cells.back().absolute_column - cells.front().absolute_column + 1 - != static_cast(cells.size())) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线网格必须是连续列"); - } - - int child_start = 0; - std::size_t first_child = source_series->children.size(); - std::size_t last_child = source_series->children.size(); - const int range_begin = cells.front().absolute_column; - const int range_end = cells.back().absolute_column + 1; - for (std::size_t index = 0; index < source_series->children.size(); ++index) - { - const ConditionExpression &child = source_series->children[index]; - const int child_end = child_start + expressionColumns(child); - if (child_end > range_begin && child_start < range_end) + return connection.upperRungId == upper_id + && connection.lowerRungId == lower_id; + }); + if (!connected) { - if (child.kind != ConditionExpressionKind::Wire) - { - return failure( - LogicEditorError::InvalidOperation, - "竖线网格不能跨越触点或并联支路"); - } - if (first_child == source_series->children.size()) - { - first_child = index; - } - last_child = index; + break; } - child_start = child_end; - } - if (first_child == source_series->children.size()) - { - return failure( - LogicEditorError::InvalidOperation, - "未找到连续的横线网格范围"); + ++group_end; } - selected_series_id = source_series->id; - selected_first_child = first_child; - selected_last_child = last_child; - int layout_start = 0; - for (std::size_t index = 0; index < first_child; ++index) - { - layout_start += expressionColumns(source_series->children[index]); - } - int selected_end = layout_start; - for (std::size_t index = first_child; - index <= last_child; - ++index) - { - selected_end += expressionColumns(source_series->children[index]); - } - leading_columns = cells.front().absolute_column - layout_start; - trailing_columns = selected_end - (cells.back().absolute_column + 1); } - - const int first_column = cells.front().absolute_column; - const int selected_columns = static_cast(cells.size()); - - std::unordered_set generated_wire_ids; - const auto makeGeneratedWireId = [&]() + const bool append_next_rung = create_output_rung + || group_end + 1U == existing_logic->rungs.size(); + const std::size_t rows_to_add = append_next_rung + ? (create_output_rung ? 2U : 1U) : 0U; + if (existing_logic->rungs.size() + rows_to_add + > project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + rows_to_add + > ProjectLimits::kMaximumRungsPerProject) { - std::string candidate = makeUniqueWireId(*logic); - while (generated_wire_ids.count(candidate) != 0U - || findExpression(logic_id, rung_id, candidate) != nullptr) - { - candidate += "-split"; - } - generated_wire_ids.insert(candidate); - return candidate; - }; - - const std::string branch_wire_id = makeGeneratedWireId(); - const std::string selected_wire_id = same_wire - ? wire_expression_id : cells.front().wire_id; - const std::string leading_wire_id = leading_columns > 0 - ? makeGeneratedWireId() : std::string{}; - const std::string trailing_wire_id = trailing_columns > 0 - ? makeGeneratedWireId() : std::string{}; - ConditionExpression branch = ConditionExpression::fromWire( - branch_wire_id, selected_columns); - const std::string parallel_id = makeUniqueExpressionId(*logic); - std::string series_id = parallel_id + "-range"; - while (findExpression(logic_id, rung_id, series_id) != nullptr) - { - series_id += "-range"; + return {failure( + LogicEditorError::InvalidOperation, + "输出后无法创建下一空行,梯形图行数已经达到当前上限"), + {}}; } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); - LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); - bool inserted = false; - if (same_wire) - { - inserted = addParallelForWireCellRange( - &*rung->condition, - wire_expression_id, - first_column, - selected_columns, - &branch, - parallel_id, - series_id, - leading_wire_id, - trailing_wire_id); - } - else + Project &project = project_service_.editProject(); + ControlLogic *logic = editableLogic(&project, logic_id); + if (create_output_rung) { - inserted = addParallelForWireCellSeriesRange( - &*rung->condition, - selected_series_id, - selected_first_child, - selected_last_child, - leading_columns, - selected_columns, - trailing_columns, - &branch, - selected_wire_id, - parallel_id, - leading_wire_id, - trailing_wire_id); - } - if (!inserted) + target_rung_id = insertEmptyRungAt(logic, logic->rungs.size()); + target_index = logic->rungs.size() - 1U; + group_end = target_index; + } + LadderRung *rung = editableRung(logic, target_rung_id); + int rightmost_content = -1; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - rollbackEdit(std::move(before), modified_before); - return failure( - LogicEditorError::InvalidOperation, - "竖线连接只能围绕同一条横线中的连续网格建立"); + if (rung->cells[static_cast(column)].kind + != LadderCellKind::Gap) + { + rightmost_content = column; + } } - normalizeConditionExpression(&rung->condition); - std::string validation_error; - if (!rung->validate(&validation_error)) + // 空网络直接输出时补满横线;已有内容时只补尾部,不跨越中间断点 + for (int column = rightmost_content + 1; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + LadderCell &cell = rung->cells[static_cast(column)]; + cell.kind = LadderCellKind::Wire; + cell.node.reset(); } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, branch_wire_id}; -} + const std::string node_id = makeUniqueId(*logic, nodePrefix(config)); + rung->output = LogicNode{node_id, config, configured}; -LogicEditorResult LogicEditorService::setOutput( - const std::string &logic_id, - const std::string &rung_id, - const LogicNodeConfig &config, - bool configured) -{ - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = rung_id.empty() - ? nullptr : findRung(logic_id, rung_id); - if (logic == nullptr || (!rung_id.empty() && existing_rung == nullptr) - || !isOutputConfig(config)) - { - return failure( - logic == nullptr ? LogicEditorError::LogicNotFound - : existing_rung == nullptr && !rung_id.empty() - ? LogicEditorError::RungNotFound - : LogicEditorError::InvalidNode, - logic == nullptr ? "未找到控制逻辑" - : existing_rung == nullptr && !rung_id.empty() - ? "未找到梯形图网络" - : "梯形图输出必须使用有效的输出指令"); - } - const std::string limit_message = rung_id.empty() - ? rungLimitMessage( - project_service_.project(), *logic, project_service_.projectLimits()) - : std::string{}; - if (!limit_message.empty()) - { - return failure(LogicEditorError::InvalidOperation, limit_message); - } - const bool create_rung = rung_id.empty(); - const std::string target_rung_id = create_rung - ? makeUniqueRungId(*logic) : rung_id; - const std::string node_id = existing_rung != nullptr - && existing_rung->output.has_value() - ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); - LogicNode node = makeNode(node_id, config); - node.configured = configured; - std::string validation_error; - if (!node.validate(&validation_error)) - { - return failure(LogicEditorError::InvalidNode, validation_error); - } - if (existing_rung != nullptr && existing_rung->output.has_value() - && nodesEqual(*existing_rung->output, node)) + std::string next_rung_id; + if (append_next_rung) { - return {true, LogicEditorError::None, {}, node_id}; + next_rung_id = insertEmptyRungAt(logic, logic->rungs.size()); } - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - if (create_rung) - { - editable_logic->rungs.push_back({ - target_rung_id, - "网络 " + std::to_string(editable_logic->rungs.size() + 1U), - {}, - std::nullopt, - std::nullopt}); + else + { + next_rung_id = logic->rungs[group_end + 1U].id; } - LadderRung *editable_rung = findEditableRung( - project, logic_id, target_rung_id); - editable_rung->output = std::move(node); - if (!repairExplicitLayout(*editable_logic, *editable_rung)) + if (!logic->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure( - LogicEditorError::InvalidOperation, - "输出前的显式横线无法在 10 列条件区内完成布局"); + return {failure(LogicEditorError::InvalidNode, error), {}}; } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; + return { + {true, LogicEditorError::None, {}, node_id}, + {next_rung_id, 0, false}}; } LogicEditorResult LogicEditorService::updateNodeConfig( @@ -2712,678 +2180,861 @@ LogicEditorResult LogicEditorService::updateNodeConfig( { return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); } - LogicNode candidate{node_id, config, true}; - if (node->isCondition() != candidate.isCondition()) + if (node->isCondition() != isConditionConfig(config) + || node->isOutput() != isOutputConfig(config)) { return failure( LogicEditorError::UnsupportedNodeChange, - "不能把条件节点改成输出节点,或把输出节点改成条件节点"); + "条件节点和输出节点不能互相改型"); } + LogicNode candidate{node_id, config, true}; std::string error; if (!candidate.validate(&error)) { return failure(LogicEditorError::InvalidNode, error); } - if (nodesEqual(*node, candidate)) - { - return {true, LogicEditorError::None, {}, node_id}; - } - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - for (ControlLogic &logic : project.controlLogics) + ControlLogic *logic = editableLogic(&project, logic_id); + for (LadderRung &rung : logic->rungs) { - if (logic.id != logic_id) - { - continue; - } - for (LadderRung &rung : logic.rungs) + for (LadderCell &cell : rung.cells) { - if (rung.output.has_value() && rung.output->id == node_id) + if (cell.node.has_value() && cell.node->id == node_id) { - *rung.output = candidate; + cell.node->config = config; + cell.node->configured = true; recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } - if (rung.condition.has_value()) - { - if (LogicNode *editable = findConditionNode(*rung.condition, node_id)) - { - *editable = candidate; - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_id}; - } - } + } + if (rung.output.has_value() && rung.output->id == node_id) + { + rung.output->config = config; + rung.output->configured = true; + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; } } - project_service_.restoreModifiedState(modified_before); return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); } -LogicEditorResult LogicEditorService::pasteConditionNodes( +LogicClipboardCopyResult LogicEditorService::copySelection( const std::string &logic_id, - const std::string &rung_id, - const std::vector &nodes, - const LogicConditionPasteTarget &target) + const LogicSelectionCopyRequest &selection) const { - const ControlLogic *existing_logic = findLogic(logic_id); - if (existing_logic == nullptr) - { - return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); - } - if (nodes.empty()) - { - return failure(LogicEditorError::InvalidOperation, "请先复制梯形图条件"); - } - if (nodes.size() > ProjectLimits::kMaximumExpressionNodesPerRung) - { - return failure(LogicEditorError::InvalidOperation, "复制的梯形图条件数量超出网络限制"); - } - for (const LogicNode &source : nodes) - { - if (!source.isCondition() || !source.validate()) - { - return failure(LogicEditorError::InvalidNode, "只能粘贴有效的梯形图条件指令"); - } - } - const LadderRung *existing_rung = rung_id.empty() - ? nullptr : findRung(logic_id, rung_id); - if (!rung_id.empty() && existing_rung == nullptr) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); - } - const std::string limit_message = rung_id.empty() - ? rungLimitMessage( - project_service_.project(), *existing_logic, - project_service_.projectLimits()) - : std::string{}; - if (!limit_message.empty()) + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) { - return failure(LogicEditorError::InvalidOperation, limit_message); + return {failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"), {}}; } - const HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - suppress_history_ = true; - std::string target_rung_id = rung_id; - std::vector new_ids; - new_ids.reserve(nodes.size()); - LogicEditorResult current; - for (std::size_t index = 0; index < nodes.size(); ++index) + const bool has_grid_objects = !selection.cells.empty() + || !selection.outputRungIds.empty() + || !selection.verticalConnectionIds.empty(); + if (!selection.wholeRungIds.empty()) { - const LogicNode &source = nodes[index]; - if (index > 0U) + if (has_grid_objects) { - current = insertConditionAfter( - logic_id, target_rung_id, new_ids.back(), source.config); + return {failure( + LogicEditorError::InvalidOperation, + "整行选择不能和网格对象混合复制"), {}}; } - else + std::unordered_set unique_ids; + std::vector indices; + indices.reserve(selection.wholeRungIds.size()); + for (const std::string &rung_id : selection.wholeRungIds) { - switch (target.kind) + const std::size_t index = rungIndex(*logic, rung_id); + if (!unique_ids.insert(rung_id).second + || index == logic->rungs.size()) { - case LogicConditionPasteTargetKind::EmptyColumn: - current = insertConditionAtColumn( - logic_id, rung_id, target.column, source.config); - break; - case LogicConditionPasteTargetKind::BranchEmptyColumn: - current = insertConditionInBranchAtColumn( - logic_id, rung_id, target.expressionId, target.column, source.config); - break; - case LogicConditionPasteTargetKind::AfterNode: - current = insertConditionAfter( - logic_id, rung_id, target.expressionId, source.config); - break; - case LogicConditionPasteTargetKind::ReplaceWire: - current = replaceWireWithCondition( - logic_id, rung_id, target.expressionId, source.config); - break; - case LogicConditionPasteTargetKind::ReplaceWireColumn: - current = replaceWireColumnWithCondition( - logic_id, rung_id, target.expressionId, target.column, source.config); - break; - case LogicConditionPasteTargetKind::ReplaceGapColumn: - current = replaceGapColumnWithCondition( - logic_id, rung_id, target.expressionId, target.column, source.config); - break; - case LogicConditionPasteTargetKind::Append: - default: - current = appendCondition(logic_id, rung_id, source.config); - break; + return {failure( + LogicEditorError::InvalidOperation, + "整行复制选择中存在重复或失效的行"), {}}; } + indices.push_back(index); } - if (!current.succeeded) + std::sort(indices.begin(), indices.end()); + for (std::size_t index = 1U; index < indices.size(); ++index) { - suppress_history_ = false; - rollbackEdit(before, modified_before); - return current; + if (indices[index] != indices[index - 1U] + 1U) + { + return {failure( + LogicEditorError::InvalidOperation, + "整行复制只支持连续的视觉行"), {}}; + } } - if (target_rung_id.empty()) + + LogicClipboardFragment fragment; + fragment.mode = LogicClipboardMode::WholeRows; + fragment.rowSpan = static_cast(indices.size()); + fragment.columnSpan = ProjectLimits::kMaximumLadderColumns; + fragment.rows.reserve(indices.size()); + for (std::size_t index : indices) { - target_rung_id = rungIdForNode(logic_id, current.id); + const LadderRung &rung = logic->rungs[index]; + fragment.rows.push_back({rung.comment, rung.cells, rung.output}); } - Project &editable_project = project_service_.editProject(); - LadderRung *editable_rung = findEditableRung( - editable_project, logic_id, target_rung_id); - LogicNode *editable_node = editable_rung != nullptr - && editable_rung->condition.has_value() - ? findConditionNode(*editable_rung->condition, current.id) : nullptr; - if (editable_node == nullptr) + const std::size_t first = indices.front(); + const std::size_t last = indices.back(); + for (const VerticalConnection &connection : logic->verticalConnections) { - suppress_history_ = false; - rollbackEdit(before, modified_before); - return failure(LogicEditorError::NodeNotFound, "粘贴后的梯形图节点无法定位"); + const std::size_t upper = rungIndex(*logic, connection.upperRungId); + const std::size_t lower = rungIndex(*logic, connection.lowerRungId); + if (upper >= first && lower <= last && lower == upper + 1U) + { + fragment.verticalConnections.push_back({ + static_cast(upper - first), + connection.columnBoundary}); + } } - editable_node->configured = source.configured; - new_ids.push_back(current.id); + return {{ + true, + LogicEditorError::None, + {}, + logic->rungs[first].id}, std::move(fragment)}; } - suppress_history_ = false; - recordHistory(before); - return {true, LogicEditorError::None, {}, new_ids.front()}; -} -bool LogicEditorService::areConditionNodesContiguous( - const std::string &logic_id, - const std::string &rung_id, - const std::vector &node_ids) const -{ - const LadderRung *rung = findRung(logic_id, rung_id); - if (rung == nullptr || !rung->condition.has_value() || node_ids.empty()) + if (!has_grid_objects) { - return false; - } - NodeIdSet selected_ids(node_ids.cbegin(), node_ids.cend()); - if (selected_ids.size() != node_ids.size()) + return {failure( + LogicEditorError::InvalidOperation, + "请先选择横线、指令、输出或竖线"), {}}; + } + + LogicClipboardFragment fragment; + fragment.mode = LogicClipboardMode::GridObjects; + std::size_t minimum_row = logic->rungs.size(); + std::size_t maximum_row = 0U; + int minimum_column = ProjectLimits::kMaximumLadderColumns; + int maximum_column = 0; + const auto include_position = [ + &minimum_row, + &maximum_row, + &minimum_column, + &maximum_column](std::size_t row, int column) + { + minimum_row = std::min(minimum_row, row); + maximum_row = std::max(maximum_row, row); + minimum_column = std::min(minimum_column, column); + maximum_column = std::max(maximum_column, column); + }; + + std::unordered_set unique_cells; + for (const auto &position : selection.cells) { - return false; + const std::size_t row = rungIndex(*logic, position.first); + const LadderCell *cell = findCell( + logic_id, position.first, position.second); + const std::string key = position.first + ":" + + std::to_string(position.second); + if (row == logic->rungs.size() || cell == nullptr + || !unique_cells.insert(key).second + || cell->kind == LadderCellKind::Gap) + { + return {failure( + LogicEditorError::InvalidOperation, + "复制选择中存在重复、空白或失效的网格"), {}}; + } + fragment.cells.push_back({ + static_cast(row), + position.second, + cell->kind, + cell->node}); + include_position(row, position.second); } - for (const std::string &node_id : node_ids) + + std::unordered_set unique_outputs; + for (const std::string &rung_id : selection.outputRungIds) { - if (findConditionNode(*rung->condition, node_id) == nullptr) + const std::size_t row = rungIndex(*logic, rung_id); + const LadderRung *rung = findRung(logic_id, rung_id); + if (row == logic->rungs.size() || rung == nullptr + || !rung->output.has_value() + || !unique_outputs.insert(rung_id).second) { - return false; + return {failure( + LogicEditorError::InvalidOperation, + "复制选择中存在重复、空白或失效的输出槽"), {}}; } + fragment.outputs.push_back({ + static_cast(row), + ProjectLimits::kMaximumConditionColumns, + *rung->output}); + include_position(row, ProjectLimits::kMaximumConditionColumns); } - if (node_ids.size() == 1U) + + std::unordered_set unique_connections; + for (const std::string &connection_id : selection.verticalConnectionIds) { - return true; + const VerticalConnection *connection = findConnection( + logic_id, connection_id); + if (connection == nullptr + || !unique_connections.insert(connection_id).second) + { + return {failure( + LogicEditorError::InvalidOperation, + "复制选择中存在重复或失效的竖线"), {}}; + } + const std::size_t upper = rungIndex(*logic, connection->upperRungId); + const std::size_t lower = rungIndex(*logic, connection->lowerRungId); + if (upper == logic->rungs.size() || lower != upper + 1U) + { + return {failure( + LogicEditorError::InvalidOperation, + "复制选择中存在悬空竖线"), {}}; + } + fragment.verticalConnections.push_back({ + static_cast(upper), connection->columnBoundary}); + include_position(upper, connection->columnBoundary); + include_position(lower, connection->columnBoundary); } - return hasContiguousDirectNodeRange(*rung->condition, selected_ids); -} -LogicEditorResult LogicEditorService::pasteRung( - const std::string &logic_id, - const LadderRung &source) -{ - const ControlLogic *existing_logic = findLogic(logic_id); - if (existing_logic == nullptr) + for (LogicClipboardCell &cell : fragment.cells) { - return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + cell.relativeRow -= static_cast(minimum_row); + cell.relativeColumn -= minimum_column; } - std::string source_error; - if (!source.validate(&source_error)) + for (LogicClipboardOutput &output : fragment.outputs) { - return failure(LogicEditorError::InvalidOperation, source_error); + output.relativeRow -= static_cast(minimum_row); + output.relativeColumn -= minimum_column; } - const std::string limit_message = rungLimitMessage( - project_service_.project(), *existing_logic, - project_service_.projectLimits()); - if (!limit_message.empty()) + for (LogicClipboardVerticalConnection &connection + : fragment.verticalConnections) { - return failure(LogicEditorError::InvalidOperation, limit_message); + connection.upperRelativeRow -= static_cast(minimum_row); + connection.relativeColumnBoundary -= minimum_column; } + fragment.rowSpan = static_cast(maximum_row - minimum_row + 1U); + fragment.columnSpan = maximum_column - minimum_column + 1; + std::sort( + fragment.cells.begin(), fragment.cells.end(), + [](const LogicClipboardCell &left, const LogicClipboardCell &right) + { + return left.relativeRow != right.relativeRow + ? left.relativeRow < right.relativeRow + : left.relativeColumn < right.relativeColumn; + }); + std::sort( + fragment.outputs.begin(), fragment.outputs.end(), + [](const LogicClipboardOutput &left, const LogicClipboardOutput &right) + { + return left.relativeRow < right.relativeRow; + }); + std::sort( + fragment.verticalConnections.begin(), + fragment.verticalConnections.end(), + [](const LogicClipboardVerticalConnection &left, + const LogicClipboardVerticalConnection &right) + { + return left.upperRelativeRow != right.upperRelativeRow + ? left.upperRelativeRow < right.upperRelativeRow + : left.relativeColumnBoundary + < right.relativeColumnBoundary; + }); + return {{true, LogicEditorError::None, {}, {}}, std::move(fragment)}; +} - const HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - NodeIdSet reserved_node_ids; - NodeIdSet reserved_expression_ids; - NodeIdSet reserved_wire_ids; - auto unique_node_id = [&editable_logic, &reserved_node_ids]( - const std::string &prefix) - { - for (std::size_t index = 1;; ++index) - { - const std::string candidate = prefix + '-' + std::to_string(index); - if (reserved_node_ids.count(candidate) == 0U - && std::none_of( - editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), - [&candidate](const LadderRung &rung) - { - return (rung.output.has_value() && rung.output->id == candidate) - || (rung.condition.has_value() - && findConditionNode(*rung.condition, candidate) != nullptr); - })) - { - reserved_node_ids.insert(candidate); - return candidate; - } - } - }; - auto unique_wire_id = [&editable_logic, &reserved_wire_ids]() - { - for (std::size_t index = 1;; ++index) - { - const std::string candidate = "wire-" + std::to_string(index); - if (reserved_wire_ids.count(candidate) == 0U - && std::none_of( - editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), - [&candidate](const LadderRung &rung) - { - return rung.condition.has_value() - && findConditionExpression(*rung.condition, candidate) != nullptr; - })) - { - reserved_wire_ids.insert(candidate); - return candidate; - } - } - }; - auto unique_expression_id = [&editable_logic, &reserved_expression_ids]() - { - for (std::size_t index = 1;; ++index) - { - const std::string candidate = "expression-" + std::to_string(index); - if (reserved_expression_ids.count(candidate) == 0U - && std::none_of( - editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), - [&candidate](const LadderRung &rung) - { - return rung.condition.has_value() - && findConditionExpression(*rung.condition, candidate) != nullptr; - })) - { - reserved_expression_ids.insert(candidate); - return candidate; - } - } - }; - auto clone_node = [&unique_node_id](const LogicNode &source_node) +LogicClipboardPasteResult LogicEditorService::pasteClipboard( + const std::string &logic_id, + const LogicClipboardFragment &fragment, + const LogicPasteTarget &target) +{ + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) { - return LogicNode{ - unique_node_id(nodePrefix(source_node.config)), - source_node.config, - source_node.configured}; - }; - std::function clone_expression; - clone_expression = [&unique_wire_id, &unique_expression_id, &clone_node, &clone_expression]( - const ConditionExpression &source_expression) + return {failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"), {}, {}}; + } + + if (fragment.mode == LogicClipboardMode::WholeRows) { - ConditionExpression clone; - clone.kind = source_expression.kind; - if (source_expression.kind == ConditionExpressionKind::Node) + if (fragment.rows.empty() || !fragment.cells.empty() + || !fragment.outputs.empty()) { - clone.node = clone_node(*source_expression.node); - clone.id = clone.node->id; + return {failure( + LogicEditorError::InvalidOperation, + "整行剪贴板内容无效"), {}, {}}; } - else if (source_expression.kind == ConditionExpressionKind::Wire) + if (logic->rungs.size() + fragment.rows.size() + > project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + fragment.rows.size() + > ProjectLimits::kMaximumRungsPerProject) { - clone.id = unique_wire_id(); - clone.wire = source_expression.wire; + return {failure( + LogicEditorError::InvalidOperation, + "粘贴整行后将超过梯形图行数上限"), {}, {}}; } - else if (source_expression.kind == ConditionExpressionKind::Gap) + std::size_t position = 0U; + if (!logic->rungs.empty()) { - clone.id = unique_expression_id(); - clone.gap = source_expression.gap; + const std::size_t reference = rungIndex(*logic, target.rungId); + if (reference == logic->rungs.size()) + { + return {failure( + LogicEditorError::RungNotFound, + "请先选择整行粘贴位置"), {}, {}}; + } + position = reference + 1U; } - else + for (const LogicClipboardRow &row : fragment.rows) { - clone.id = unique_expression_id(); - for (const ConditionExpression &child : source_expression.children) + if (row.cells.size() + != static_cast( + ProjectLimits::kMaximumConditionColumns) + || containsLineBreak(row.comment) + || row.comment.size() > ProjectLimits::kMaximumRungCommentBytes) { - clone.children.push_back(clone_expression(child)); + return {failure( + LogicEditorError::InvalidOperation, + "复制的整行结构或注释无效"), {}, {}}; + } + for (const LadderCell &cell : row.cells) + { + if (!cell.validate() + || (cell.node.has_value() && !cell.node->isCondition())) + { + return {failure( + LogicEditorError::InvalidNode, + "复制的整行包含无效条件"), {}, {}}; + } + } + if (row.output.has_value() + && (!row.output->validate() || !row.output->isOutput())) + { + return {failure( + LogicEditorError::InvalidNode, + "复制的整行包含无效输出"), {}, {}}; + } + } + std::unordered_set unique_connections; + for (const LogicClipboardVerticalConnection &connection + : fragment.verticalConnections) + { + const std::string key = std::to_string(connection.upperRelativeRow) + + ":" + std::to_string(connection.relativeColumnBoundary); + if (connection.upperRelativeRow < 0 + || connection.upperRelativeRow + 1 + >= static_cast(fragment.rows.size()) + || connection.relativeColumnBoundary < 0 + || connection.relativeColumnBoundary + > ProjectLimits::kMaximumConditionColumns + || !unique_connections.insert(key).second) + { + return {failure( + LogicEditorError::InvalidOperation, + "复制的整行包含无效竖线"), {}, {}}; } } - return clone; - }; - LadderRung pasted = source; - pasted.id = makeUniqueRungId(*editable_logic); - if (source.condition.has_value()) - { - pasted.condition = clone_expression(*source.condition); - } - if (source.output.has_value()) - { - pasted.output = clone_node(*source.output); - } - std::string validation_error; - if (!pasted.validate(&validation_error)) - { - rollbackEdit(before, modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + ControlLogic *editable = editableLogic(&project, logic_id); + std::vector inserted_ids; + inserted_ids.reserve(fragment.rows.size()); + for (std::size_t offset = 0U; offset < fragment.rows.size(); ++offset) + { + const std::string inserted_id = insertEmptyRungAt( + editable, position + offset); + LadderRung *destination = editableRung(editable, inserted_id); + const LogicClipboardRow &source = fragment.rows[offset]; + destination->comment = source.comment; + for (std::size_t column = 0U; column < source.cells.size(); ++column) + { + destination->cells[column].kind = source.cells[column].kind; + destination->cells[column].node.reset(); + if (source.cells[column].node.has_value()) + { + const LogicNode &source_node = *source.cells[column].node; + destination->cells[column].node = LogicNode{ + makeUniqueId(*editable, nodePrefix(source_node.config)), + source_node.config, + source_node.configured}; + } + } + if (source.output.has_value()) + { + destination->output = LogicNode{ + makeUniqueId(*editable, nodePrefix(source.output->config)), + source.output->config, + source.output->configured}; + } + inserted_ids.push_back(inserted_id); + } + for (const LogicClipboardVerticalConnection &source + : fragment.verticalConnections) + { + const std::string &upper = inserted_ids[ + static_cast(source.upperRelativeRow)]; + const std::string &lower = inserted_ids[ + static_cast(source.upperRelativeRow + 1)]; + const auto existing = std::find_if( + editable->verticalConnections.cbegin(), + editable->verticalConnections.cend(), + [&upper, &lower, &source](const VerticalConnection &connection) + { + return connectionMatches( + connection, + upper, + lower, + source.relativeColumnBoundary); + }); + if (existing == editable->verticalConnections.cend()) + { + editable->verticalConnections.push_back({ + makeUniqueId(*editable, "vertical"), + upper, + lower, + source.relativeColumnBoundary}); + } + } + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) + { + rollbackEdit(std::move(before), modified_before); + return {failure(LogicEditorError::InvalidOperation, error), {}, {}}; + } + refreshRungNames(editable); + recordHistory(std::move(before)); + return {{ + true, + LogicEditorError::None, + {}, + inserted_ids.front()}, {}, std::move(inserted_ids)}; } - editable_logic->rungs.push_back(std::move(pasted)); - recordHistory(before); - return {true, LogicEditorError::None, {}, editable_logic->rungs.back().id}; -} -LogicEditorResult LogicEditorService::removeNode( - const std::string &logic_id, const std::string &node_id) -{ - return removeNodes(logic_id, {node_id}); -} -LogicEditorResult LogicEditorService::removeNodes( - const std::string &logic_id, - const std::vector &node_ids) -{ - if (findLogic(logic_id) == nullptr) + if (!fragment.rows.empty() + || (fragment.cells.empty() && fragment.outputs.empty() + && fragment.verticalConnections.empty()) + || fragment.rowSpan <= 0 || fragment.columnSpan <= 0) { - return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + return {failure( + LogicEditorError::InvalidOperation, + "网格剪贴板内容无效"), {}, {}}; } - if (node_ids.empty()) + const std::size_t target_row = rungIndex(*logic, target.rungId); + if (target_row == logic->rungs.size()) { - return failure(LogicEditorError::InvalidOperation, "请先选择要删除的逻辑节点"); + return {failure( + LogicEditorError::RungNotFound, + "请先选择粘贴目标"), {}, {}}; } - std::unordered_set selected_ids; - for (const std::string &node_id : node_ids) + const bool only_vertical = fragment.cells.empty() + && fragment.outputs.empty(); + const bool only_output = fragment.cells.empty() + && fragment.verticalConnections.empty(); + if ((only_vertical && (!target.boundary || target.output)) + || (only_output && (!target.output || target.boundary))) { - if (!selected_ids.insert(node_id).second) - { - return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复节点"); - } - if (findNode(logic_id, node_id) == nullptr) - { - return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); - } + return {failure( + LogicEditorError::InvalidOperation, + "剪贴板对象类型与当前粘贴位置不匹配"), {}, {}}; } - - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); - Project &project = project_service_.editProject(); - ControlLogic *editable_logic = nullptr; - for (ControlLogic &candidate : project.controlLogics) + if (target.column < 0 + || target.column > ProjectLimits::kMaximumConditionColumns + || target_row + static_cast(fragment.rowSpan) + > logic->rungs.size()) { - if (candidate.id == logic_id) + return {failure( + LogicEditorError::InvalidOperation, + "粘贴片段超出当前梯形图行列范围"), {}, {}}; + } + + std::unordered_set destination_cells; + for (const LogicClipboardCell &source : fragment.cells) + { + const int column = target.column + source.relativeColumn; + const std::size_t row = target_row + + static_cast(source.relativeRow); + const std::string key = std::to_string(row) + ":" + + std::to_string(column); + if (source.relativeRow < 0 + || source.relativeRow >= fragment.rowSpan + || source.relativeColumn < 0 + || column < 0 + || column >= ProjectLimits::kMaximumConditionColumns + || !destination_cells.insert(key).second + || (source.kind != LadderCellKind::Wire + && source.kind != LadderCellKind::Node) + || (source.kind == LadderCellKind::Node + && (!source.node.has_value() + || !source.node->validate() + || !source.node->isCondition())) + || (source.kind == LadderCellKind::Wire + && source.node.has_value())) + { + return {failure( + LogicEditorError::InvalidOperation, + "复制片段包含无效或越界的条件网格"), {}, {}}; + } + const LadderCell &destination = logic->rungs[row].cells[ + static_cast(column)]; + if (destination.kind == LadderCellKind::Node) { - editable_logic = &candidate; - break; + return {failure( + LogicEditorError::InvalidOperation, + "粘贴目标已有触点或比较指令,未执行任何修改"), {}, {}}; } } - for (LadderRung &rung : editable_logic->rungs) + + const bool explicit_output_replace = only_output + && fragment.outputs.size() == 1U && fragment.rowSpan == 1; + std::unordered_set destination_outputs; + for (const LogicClipboardOutput &source : fragment.outputs) { - if (rung.output.has_value() - && selected_ids.find(rung.output->id) != selected_ids.end()) + const int column = target.column + source.relativeColumn; + const std::size_t row = target_row + + static_cast(source.relativeRow); + if (source.relativeRow < 0 + || source.relativeRow >= fragment.rowSpan + || source.relativeColumn < 0 + || column != ProjectLimits::kMaximumConditionColumns + || !destination_outputs.insert(row).second + || !source.node.validate() || !source.node.isOutput()) { - rung.output.reset(); + return {failure( + LogicEditorError::InvalidOperation, + "复制片段包含无效或错位的输出"), {}, {}}; } - if (rung.condition.has_value()) + if (logic->rungs[row].output.has_value() && !explicit_output_replace) { - for (const std::string &node_id : node_ids) - { - ConditionExpression *expression = findConditionExpression( - *rung.condition, node_id); - if (expression != nullptr - && expression->kind == ConditionExpressionKind::Node) - { - *expression = ConditionExpression::fromGap( - makeUniqueGapId(*editable_logic), 1); - } - } - normalizeConditionExpression(&rung.condition); + return {failure( + LogicEditorError::InvalidOperation, + "混合片段的目标输出槽已有指令,未执行任何修改"), {}, {}}; } } - for (const LadderRung &rung : editable_logic->rungs) + + std::unordered_set destination_connections; + for (const LogicClipboardVerticalConnection &source + : fragment.verticalConnections) { - std::string validation_error; - if (!rung.validate(&validation_error)) + const int boundary = target.column + source.relativeColumnBoundary; + const std::size_t upper = target_row + + static_cast(source.upperRelativeRow); + const std::string key = std::to_string(upper) + ":" + + std::to_string(boundary); + if (source.upperRelativeRow < 0 + || source.upperRelativeRow + 1 >= fragment.rowSpan + || source.relativeColumnBoundary < 0 + || upper + 1U >= logic->rungs.size() + || boundary < 0 + || boundary > ProjectLimits::kMaximumConditionColumns + || !destination_connections.insert(key).second) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return {failure( + LogicEditorError::InvalidOperation, + "复制片段包含无效、重复或悬空的竖线"), {}, {}}; } } - recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, node_ids.front()}; -} -LogicEditorResult LogicEditorService::removeExpression( - const std::string &logic_id, - const std::string &rung_id, - const std::string &expression_id) -{ - if (findExpression(logic_id, rung_id, expression_id) == nullptr) - { - return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); - } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - if (rung->condition->id == expression_id) + ControlLogic *editable = editableLogic(&project, logic_id); + LogicClipboardPasteResult result; + for (const LogicClipboardCell &source : fragment.cells) + { + const std::size_t row = target_row + + static_cast(source.relativeRow); + const int column = target.column + source.relativeColumn; + LadderCell &destination = editable->rungs[row].cells[ + static_cast(column)]; + destination.kind = source.kind; + destination.node.reset(); + if (source.node.has_value()) + { + const std::string node_id = makeUniqueId( + *editable, nodePrefix(source.node->config)); + destination.node = LogicNode{ + node_id, source.node->config, source.node->configured}; + if (result.edit.id.empty()) + { + result.edit.id = node_id; + } + } + result.selection.cells.emplace_back(editable->rungs[row].id, column); + if (result.edit.id.empty()) + { + result.edit.id = destination.id; + } + } + for (const LogicClipboardOutput &source : fragment.outputs) { - rung->condition.reset(); + const std::size_t row = target_row + + static_cast(source.relativeRow); + const std::string node_id = makeUniqueId( + *editable, nodePrefix(source.node.config)); + editable->rungs[row].output = LogicNode{ + node_id, source.node.config, source.node.configured}; + result.selection.outputRungIds.push_back(editable->rungs[row].id); + if (result.edit.id.empty()) + { + result.edit.id = node_id; + } } - else + for (const LogicClipboardVerticalConnection &source + : fragment.verticalConnections) { - removeExpressionRecursive(&*rung->condition, expression_id); - normalizeConditionExpression(&rung->condition); + const std::size_t upper = target_row + + static_cast(source.upperRelativeRow); + const int boundary = target.column + source.relativeColumnBoundary; + const std::string &upper_id = editable->rungs[upper].id; + const std::string &lower_id = editable->rungs[upper + 1U].id; + auto existing = std::find_if( + editable->verticalConnections.begin(), + editable->verticalConnections.end(), + [&upper_id, &lower_id, boundary](const VerticalConnection &connection) + { + return connectionMatches( + connection, upper_id, lower_id, boundary); + }); + if (existing == editable->verticalConnections.end()) + { + editable->verticalConnections.push_back({ + makeUniqueId(*editable, "vertical"), + upper_id, + lower_id, + boundary}); + existing = std::prev(editable->verticalConnections.end()); + } + result.selection.verticalConnectionIds.push_back(existing->id); + if (result.edit.id.empty()) + { + result.edit.id = existing->id; + } } - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return {failure(LogicEditorError::InvalidOperation, error), {}, {}}; } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, expression_id}; + result.edit.succeeded = true; + result.edit.error = LogicEditorError::None; + return result; } -LogicEditorResult LogicEditorService::removeExpressions( +LogicEditorResult LogicEditorService::pasteConditionNodes( const std::string &logic_id, const std::string &rung_id, - const std::vector &expression_ids) + const std::vector &nodes, + int start_column) { - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (existing_rung == nullptr || !existing_rung->condition.has_value()) + const ControlLogic *logic = findLogic(logic_id); + const LadderRung *rung = findRung(logic_id, rung_id); + if (logic == nullptr || rung == nullptr) { - return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); + return failure(LogicEditorError::RungNotFound, "未找到粘贴目标行"); } - if (expression_ids.empty()) + if (nodes.empty() + || nodes.size() + > static_cast(ProjectLimits::kMaximumConditionColumns)) { - return failure(LogicEditorError::InvalidOperation, "请先选择要删除的条件或横线"); + return failure(LogicEditorError::InvalidOperation, "复制的条件数量无效"); } - NodeIdSet unique_ids; - for (const std::string &expression_id : expression_ids) + for (const LogicNode &node : nodes) { - const ConditionExpression *expression = findExpression( - logic_id, rung_id, expression_id); - if (!unique_ids.insert(expression_id).second) + if (!node.validate() || !node.isCondition()) { - return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复对象"); + return failure(LogicEditorError::InvalidNode, "只能粘贴有效的条件节点"); } - if (expression == nullptr) + } + if (start_column < 0) + { + for (int candidate = 0; + candidate + static_cast(nodes.size()) + <= ProjectLimits::kMaximumConditionColumns; + ++candidate) { - return failure( - LogicEditorError::ExpressionNotFound, - "删除列表中包含未知条件表达式"); + bool available = true; + for (std::size_t offset = 0U; offset < nodes.size(); ++offset) + { + available = available + && rung->cells[static_cast(candidate) + offset].kind + == LadderCellKind::Gap; + } + if (available) + { + start_column = candidate; + break; + } } } + if (start_column < 0 + || start_column + static_cast(nodes.size()) + > ProjectLimits::kMaximumConditionColumns) + { + return failure(LogicEditorError::InvalidOperation, "目标行没有足够连续空格"); + } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung( - project, logic_id, rung_id); - for (const std::string &expression_id : expression_ids) + ControlLogic *editable = editableLogic(&project, logic_id); + LadderRung *target = editableRung(editable, rung_id); + std::string first_id; + for (std::size_t offset = 0U; offset < nodes.size(); ++offset) { - if (!rung->condition.has_value()) + LadderCell &cell = target->cells[ + static_cast(start_column) + offset]; + const std::string id = makeUniqueId( + *editable, nodePrefix(nodes[offset].config)); + cell.kind = LadderCellKind::Node; + cell.node = LogicNode{id, nodes[offset].config, nodes[offset].configured}; + if (first_id.empty()) { - break; - } - if (rung->condition->id == expression_id) - { - rung->condition.reset(); - break; + first_id = id; } - removeExpressionRecursive(&*rung->condition, expression_id); } - normalizeConditionExpression(&rung->condition); - std::string validation_error; - if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) - || !rung->validate(&validation_error)) + std::string error; + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, expression_ids.front()}; + return {true, LogicEditorError::None, {}, first_id}; } -LogicEditorResult LogicEditorService::disconnectWireCells( +bool LogicEditorService::areConditionNodesContiguous( const std::string &logic_id, const std::string &rung_id, - const std::vector> &wire_cells) + const std::vector &node_ids) const { - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr - || !existing_rung->condition.has_value()) - { - return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); - } - if (wire_cells.empty()) + const LadderRung *rung = findRung(logic_id, rung_id); + if (rung == nullptr || node_ids.empty()) { - return failure(LogicEditorError::InvalidOperation, "请先选择要断开的横线网格"); + return false; } - std::vector> cells = wire_cells; - std::sort( - cells.begin(), cells.end(), - [](const auto &left, const auto &right) - { - return left.first < right.first - || (left.first == right.first && left.second > right.second); - }); - if (std::adjacent_find(cells.cbegin(), cells.cend()) != cells.cend()) + std::unordered_set requested( + node_ids.cbegin(), node_ids.cend()); + if (requested.size() != node_ids.size()) { - return failure(LogicEditorError::InvalidOperation, "横线断开列表中存在重复网格"); + return false; } - for (const auto &cell : cells) + std::vector columns; + for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { - const ConditionExpression *wire = findExpression( - logic_id, rung_id, cell.first); - if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire - || cell.second < 0 || cell.second >= wire->wire->columnSpan) + const LadderCell &cell = rung->cells[static_cast(column)]; + if (cell.node.has_value() && requested.count(cell.node->id) != 0U) { - return failure(LogicEditorError::InvalidOperation, "横线网格偏移超出有效范围"); + columns.push_back(column); } } + return columns.size() == node_ids.size() + && columns.back() - columns.front() + 1 + == static_cast(columns.size()); +} +LogicEditorResult LogicEditorService::pasteRung( + const std::string &logic_id, + const LadderRung &source) +{ + const ControlLogic *logic = findLogic(logic_id); + std::string error; + if (logic == nullptr) + { + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + } + if (!source.validateStructure(&error)) + { + return failure(LogicEditorError::InvalidOperation, error); + } + if (logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) + { + return failure(LogicEditorError::InvalidOperation, "梯形图行数已经达到上限"); + } HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - for (const auto &cell : cells) + ControlLogic *editable = editableLogic(&project, logic_id); + LadderRung pasted = makeEmptyRung(*editable, editable->rungs.size()); + pasted.comment = source.comment; + for (std::size_t column = 0U; column < source.cells.size(); ++column) { - ConditionExpression *wire = findConditionExpression( - *rung->condition, cell.first); - if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire - || cell.second < 0 || cell.second >= wire->wire->columnSpan) - { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, "横线网格断开位置已经失效"); - } - const int leading_columns = cell.second; - const int trailing_columns = wire->wire->columnSpan - cell.second - 1; - if (leading_columns == 0 && trailing_columns == 0) + pasted.cells[column].kind = source.cells[column].kind; + if (source.cells[column].node.has_value()) { - *wire = ConditionExpression::fromGap( - makeUniqueGapId(*editable_logic), 1); - continue; - } - const std::string original_wire_id = wire->id; - ConditionExpression replacement; - replacement.id = makeUniqueExpressionId(*editable_logic); - replacement.kind = ConditionExpressionKind::Series; - if (leading_columns > 0) - { - replacement.children.push_back(ConditionExpression::fromWire( - original_wire_id, leading_columns)); - } - replacement.children.push_back(ConditionExpression::fromGap( - makeUniqueGapId(*editable_logic), 1)); - if (trailing_columns > 0) - { - replacement.children.push_back(ConditionExpression::fromWire( - leading_columns > 0 - ? makeUniqueWireId(*editable_logic) : original_wire_id, - trailing_columns)); + const LogicNode &source_node = *source.cells[column].node; + pasted.cells[column].node = LogicNode{ + makeUniqueId(*editable, nodePrefix(source_node.config)), + source_node.config, + source_node.configured}; } - *wire = std::move(replacement); - normalizeConditionExpression(&rung->condition); } - std::string validation_error; - if (!rung->validate(&validation_error)) + if (source.output.has_value()) + { + pasted.output = LogicNode{ + makeUniqueId(*editable, nodePrefix(source.output->config)), + source.output->config, + source.output->configured}; + } + const std::string pasted_id = pasted.id; + editable->rungs.push_back(std::move(pasted)); + refreshRungNames(editable); + if (!editable->validateStructure(project_service_.projectLimits(), &error)) { rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + return failure(LogicEditorError::InvalidOperation, error); } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, cells.front().first}; + return {true, LogicEditorError::None, {}, pasted_id}; +} + +LogicEditorResult LogicEditorService::removeNode( + const std::string &logic_id, const std::string &node_id) +{ + return removeNodes(logic_id, {node_id}); } -LogicEditorResult LogicEditorService::disconnectWires( +LogicEditorResult LogicEditorService::removeNodes( const std::string &logic_id, - const std::string &rung_id, - const std::vector &wire_ids) + const std::vector &node_ids) { - const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr - || !existing_rung->condition.has_value()) + if (findLogic(logic_id) == nullptr) { - return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - if (wire_ids.empty()) + if (node_ids.empty()) { - return failure(LogicEditorError::InvalidOperation, "请先选择要断开的横线"); + return failure(LogicEditorError::InvalidOperation, "请先选择要删除的节点"); } - NodeIdSet unique_ids; - for (const std::string &wire_id : wire_ids) + std::unordered_set ids; + for (const std::string &id : node_ids) { - const ConditionExpression *wire = findExpression(logic_id, rung_id, wire_id); - if (!unique_ids.insert(wire_id).second - || wire == nullptr || wire->kind != ConditionExpressionKind::Wire) + if (!ids.insert(id).second || findNode(logic_id, id) == nullptr) { - return failure(LogicEditorError::ExpressionNotFound, "断开列表中包含无效横线"); + return failure( + LogicEditorError::NodeNotFound, + "节点删除列表包含重复或不存在的对象"); } } - HistoryState before = captureState(); - const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - auto editable_logic = std::find_if( - project.controlLogics.begin(), project.controlLogics.end(), - [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); - for (const std::string &wire_id : wire_ids) - { - ConditionExpression *wire = findConditionExpression( - *rung->condition, wire_id); - *wire = ConditionExpression::fromGap( - makeUniqueGapId(*editable_logic), wire->wire->columnSpan); - } - normalizeConditionExpression(&rung->condition); - std::string validation_error; - if (!rung->validate(&validation_error)) + ControlLogic *logic = editableLogic(&project, logic_id); + for (LadderRung &rung : logic->rungs) { - rollbackEdit(std::move(before), modified_before); - return failure(LogicEditorError::InvalidOperation, validation_error); + for (LadderCell &cell : rung.cells) + { + if (cell.node.has_value() && ids.count(cell.node->id) != 0U) + { + cell.kind = LadderCellKind::Gap; + cell.node.reset(); + } + } + if (rung.output.has_value() && ids.count(rung.output->id) != 0U) + { + rung.output.reset(); + } } recordHistory(std::move(before)); - return {true, LogicEditorError::None, {}, wire_ids.front()}; + return {true, LogicEditorError::None, {}, node_ids.front()}; } bool LogicEditorService::canUndo() const @@ -3423,6 +3074,115 @@ void LogicEditorService::clearHistory() history_.clear(); } +LogicEditResult LogicEditorService::applyCellAndAdvance( + const std::string &logic_id, + const LogicEditCursor &cursor, + const LogicNodeConfig *config, + bool configured) +{ + const ControlLogic *existing_logic = findLogic(logic_id); + if (existing_logic == nullptr) + { + return {failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"), {}}; + } + if (cursor.output || cursor.column < 0 + || cursor.column >= ProjectLimits::kMaximumConditionColumns) + { + return {failure(LogicEditorError::CellNotFound, + "条件指令只能放在第 1~10 列"), {}}; + } + if (config != nullptr) + { + if (!isConditionConfig(*config)) + { + return {failure(LogicEditorError::InvalidNode, + "条件区只能放置条件指令"), {}}; + } + LogicNode candidate{"candidate", *config, configured}; + std::string candidate_error; + if (!candidate.validate(&candidate_error)) + { + return {failure(LogicEditorError::InvalidNode, candidate_error), {}}; + } + } + + std::string target_rung_id = cursor.rungId; + int target_column = cursor.column; + const bool create_first_rung = target_rung_id.empty(); + if (create_first_rung) + { + if (!existing_logic->rungs.empty()) + { + return {failure(LogicEditorError::RungNotFound, + "请先选择要编辑的梯形图行"), {}}; + } + if (existing_logic->rungs.size() + >= project_service_.projectLimits().maximumRungsPerLogic + || totalRungCount(project_service_.project()) + >= ProjectLimits::kMaximumRungsPerProject) + { + return {failure(LogicEditorError::InvalidOperation, + "梯形图行数已经达到当前上限"), {}}; + } + target_column = 0; + } + else + { + const LadderCell *existing_cell = findCell( + logic_id, target_rung_id, target_column); + if (existing_cell == nullptr) + { + return {failure(LogicEditorError::CellNotFound, + "未找到目标条件网格"), {}}; + } + if (config == nullptr && existing_cell->kind == LadderCellKind::Node) + { + return {failure(LogicEditorError::InvalidOperation, + "横线不能覆盖已有条件指令"), {}}; + } + } + + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + ControlLogic *logic = editableLogic(&project, logic_id); + if (create_first_rung) + { + target_rung_id = insertEmptyRungAt(logic, 0U); + } + LadderCell &cell = editableRung(logic, target_rung_id) + ->cells[static_cast(target_column)]; + std::string edited_id = cell.id; + if (config == nullptr) + { + cell.kind = LadderCellKind::Wire; + cell.node.reset(); + } + else + { + edited_id = makeUniqueId(*logic, nodePrefix(*config)); + cell.kind = LadderCellKind::Node; + cell.node = LogicNode{edited_id, *config, configured}; + } + + std::string error; + if (!logic->validateStructure(project_service_.projectLimits(), &error)) + { + rollbackEdit(std::move(before), modified_before); + return {failure(LogicEditorError::InvalidOperation, error), {}}; + } + recordHistory(std::move(before)); + LogicEditCursor next{target_rung_id, target_column + 1, false}; + if (next.column >= ProjectLimits::kMaximumConditionColumns) + { + next.column = ProjectLimits::kMaximumConditionColumns; + next.output = true; + } + return { + {true, LogicEditorError::None, {}, edited_id}, + std::move(next)}; +} + bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config) { return std::holds_alternative(config) @@ -3471,196 +3231,190 @@ std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config) config); } -std::string LogicEditorService::makeUniqueNodeId( +std::string LogicEditorService::makeUniqueId( const ControlLogic &logic, const std::string &prefix) { - for (std::size_t index = 1;; ++index) + const auto exists = [&logic](const std::string &candidate) { - const std::string candidate = prefix + '-' + std::to_string(index); - bool found = false; - for (const LadderRung &rung : logic.rungs) + if (logic.id == candidate) { - found = found || (rung.output.has_value() && rung.output->id == candidate) - || (rung.condition.has_value() - && findConditionNode(*rung.condition, candidate) != nullptr); + return true; } - if (!found) + for (const LadderRung &rung : logic.rungs) { - return candidate; + if (rung.id == candidate + || (rung.output.has_value() && rung.output->id == candidate)) + { + return true; + } + for (const LadderCell &cell : rung.cells) + { + if (cell.id == candidate + || (cell.node.has_value() && cell.node->id == candidate)) + { + return true; + } + } } - } -} - -std::string LogicEditorService::makeUniqueWireId(const ControlLogic &logic) -{ - for (std::size_t index = 1;; ++index) - { - const std::string candidate = "wire-" + std::to_string(index); - const bool found = std::any_of( - logic.rungs.cbegin(), logic.rungs.cend(), - [&candidate](const LadderRung &rung) + return std::any_of( + logic.verticalConnections.cbegin(), + logic.verticalConnections.cend(), + [&candidate](const VerticalConnection &connection) { - return rung.condition.has_value() - && findConditionExpression(*rung.condition, candidate) != nullptr; + return connection.id == candidate; }); - if (!found) + }; + for (std::size_t index = 1U;; ++index) + { + const std::string candidate = prefix + '-' + std::to_string(index); + if (!exists(candidate)) { return candidate; } } } -std::string LogicEditorService::makeUniqueGapId(const ControlLogic &logic) +LadderRung LogicEditorService::makeEmptyRung( + const ControlLogic &logic, std::size_t visual_index) { - for (std::size_t index = 1;; ++index) - { - const std::string candidate = "gap-" + std::to_string(index); - const bool found = std::any_of( - logic.rungs.cbegin(), logic.rungs.cend(), - [&candidate](const LadderRung &rung) - { - return rung.condition.has_value() - && findConditionExpression(*rung.condition, candidate) != nullptr; - }); - if (!found) - { - return candidate; - } + LadderRung rung; + rung.id = makeUniqueId(logic, "rung"); + rung.name = "行 " + std::to_string(visual_index + 1U); + rung.cells.reserve( + static_cast(ProjectLimits::kMaximumConditionColumns)); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + rung.id + "-cell-" + std::to_string(column + 1), + LadderCellKind::Gap, + std::nullopt}); } + return rung; } -std::string LogicEditorService::makeUniqueExpressionId(const ControlLogic &logic) +std::string LogicEditorService::insertEmptyRungAt( + ControlLogic *logic, std::size_t position) { - for (std::size_t index = 1;; ++index) + if (logic == nullptr || position > logic->rungs.size()) { - const std::string candidate = "expression-" + std::to_string(index); - bool found = false; - for (const LadderRung &rung : logic.rungs) - { - found = found || (rung.condition.has_value() - && findConditionExpression(*rung.condition, candidate) != nullptr); - } - if (!found) - { - return candidate; - } + return {}; } -} - -std::string LogicEditorService::makeUniqueRungId(const ControlLogic &logic) -{ - for (std::size_t index = 1;; ++index) + const std::string upper_id = position > 0U + ? logic->rungs[position - 1U].id : std::string{}; + const std::string lower_id = position < logic->rungs.size() + ? logic->rungs[position].id : std::string{}; + std::vector bridges; + if (!upper_id.empty() && !lower_id.empty()) { - const std::string candidate = "rung-" + std::to_string(index); - if (std::none_of( - logic.rungs.cbegin(), logic.rungs.cend(), - [&candidate](const LadderRung &rung) { return rung.id == candidate; })) + for (const VerticalConnection &connection : logic->verticalConnections) { - return candidate; + if (connection.upperRungId == upper_id + && connection.lowerRungId == lower_id) + { + bridges.push_back(connection); + } } + logic->verticalConnections.erase( + std::remove_if( + logic->verticalConnections.begin(), + logic->verticalConnections.end(), + [&upper_id, &lower_id](const VerticalConnection &connection) + { + return connection.upperRungId == upper_id + && connection.lowerRungId == lower_id; + }), + logic->verticalConnections.end()); + } + LadderRung rung = makeEmptyRung(*logic, position); + const std::string new_id = rung.id; + logic->rungs.insert( + logic->rungs.begin() + static_cast(position), + std::move(rung)); + for (VerticalConnection &bridge : bridges) + { + bridge.lowerRungId = new_id; + logic->verticalConnections.push_back(bridge); } + for (const VerticalConnection &bridge : bridges) + { + logic->verticalConnections.push_back({ + makeUniqueId(*logic, "vertical"), + new_id, + lower_id, + bridge.columnBoundary}); + } + refreshRungNames(logic); + return new_id; } -bool LogicEditorService::repairExplicitLayout( - ControlLogic &logic, LadderRung &rung, std::string *error) +void LogicEditorService::removeRungAt( + ControlLogic *logic, std::size_t position) { - const auto append_wire = [&logic](ConditionExpression *expression, int columns) + if (logic == nullptr || position >= logic->rungs.size()) + { + return; + } + const std::string removed_id = logic->rungs[position].id; + const std::string upper_id = position > 0U + ? logic->rungs[position - 1U].id : std::string{}; + const std::string lower_id = position + 1U < logic->rungs.size() + ? logic->rungs[position + 1U].id : std::string{}; + std::map upper_connections; + std::map lower_connections; + for (const VerticalConnection &connection : logic->verticalConnections) { - if (expression == nullptr || columns <= 0) + if (connection.upperRungId == upper_id + && connection.lowerRungId == removed_id) { - return; + upper_connections[connection.columnBoundary] = connection.id; } - ConditionExpression wire = ConditionExpression::fromWire( - makeUniqueWireId(logic), columns); - if (expression->kind == ConditionExpressionKind::Series) + if (connection.upperRungId == removed_id + && connection.lowerRungId == lower_id) { - expression->children.push_back(std::move(wire)); - return; + lower_connections[connection.columnBoundary] = connection.id; } - const std::string container_id = makeUniqueExpressionId(logic); - ConditionExpression original = std::move(*expression); - *expression = makeContainer( - container_id, - ConditionExpressionKind::Series, - std::move(original), - std::move(wire)); - }; - - std::function materialize_parallel_wires; - materialize_parallel_wires = - [&](ConditionExpression *expression) - { - if (expression == nullptr - || expression->kind == ConditionExpressionKind::Node - || expression->kind == ConditionExpressionKind::Wire - || expression->kind == ConditionExpressionKind::Gap) - { - return; - } - for (ConditionExpression &child : expression->children) - { - materialize_parallel_wires(&child); - } - if (expression->kind != ConditionExpressionKind::Parallel) - { - return; - } - int maximum_columns = 0; - for (const ConditionExpression &child : expression->children) - { - maximum_columns = std::max( - maximum_columns, expressionColumns(child)); - } - for (ConditionExpression &child : expression->children) - { - append_wire( - &child, maximum_columns - expressionColumns(child)); - } - }; - - if (rung.condition.has_value()) - { - materialize_parallel_wires(&*rung.condition); } - if (!rung.output.has_value()) - { - normalizeConditionExpression(&rung.condition); - return true; - } - if (!rung.condition.has_value()) - { - rung.condition = ConditionExpression::fromWire( - makeUniqueWireId(logic), ProjectLimits::kMaximumConditionColumns); - return true; - } - - int columns = expressionColumns(*rung.condition); - if (columns > ProjectLimits::kMaximumConditionColumns) + logic->verticalConnections.erase( + std::remove_if( + logic->verticalConnections.begin(), + logic->verticalConnections.end(), + [&removed_id](const VerticalConnection &connection) + { + return connection.upperRungId == removed_id + || connection.lowerRungId == removed_id; + }), + logic->verticalConnections.end()); + logic->rungs.erase( + logic->rungs.begin() + static_cast(position)); + if (!upper_id.empty() && !lower_id.empty()) { - int overflow = columns - ProjectLimits::kMaximumConditionColumns; - if (!trimTrailingGridColumns(&*rung.condition, &overflow)) + for (const auto &upper : upper_connections) { - if (error != nullptr) + if (lower_connections.count(upper.first) != 0U) { - *error = "显式梯形图布局无法在 10 列条件区内完成"; + logic->verticalConnections.push_back({ + upper.second, + upper_id, + lower_id, + upper.first}); } - return false; } - normalizeConditionExpression(&rung.condition); - columns = expressionColumns(*rung.condition); } - append_wire( - &*rung.condition, - ProjectLimits::kMaximumConditionColumns - columns); - normalizeConditionExpression(&rung.condition); - const bool valid_width = rung.condition.has_value() - && expressionColumns(*rung.condition) - == ProjectLimits::kMaximumConditionColumns; - if (!valid_width && error != nullptr) + refreshRungNames(logic); +} + +void LogicEditorService::refreshRungNames(ControlLogic *logic) +{ + if (logic == nullptr) + { + return; + } + for (std::size_t index = 0U; index < logic->rungs.size(); ++index) { - *error = "显式梯形图布局无法在 10 列条件区内完成"; + logic->rungs[index].name = "行 " + std::to_string(index + 1U); } - return valid_width; } LogicEditorResult LogicEditorService::failure( diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index 71e7665..a439ed1 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -9,357 +9,361 @@ class ProjectService; -// 梯形图编辑失败分类 enum class LogicEditorError { - None, // 操作成功或没有错误 - LogicNotFound, // 控制逻辑不存在 - RungNotFound, // 网络不存在 - ExpressionNotFound, // 条件表达式不存在 - NodeNotFound, // 节点不存在 - InvalidNode, // 节点配置或节点位置无效 - InvalidOperation, // 操作参数或当前结构不允许该操作 - UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改 - DuplicateName, // 控制逻辑名称与其他逻辑重复 - LastLogicRequired // 删除后不能少于一个控制逻辑 + None, + LogicNotFound, + RungNotFound, + CellNotFound, + ConnectionNotFound, + NodeNotFound, + InvalidNode, + InvalidOperation, + UnsupportedNodeChange, + DuplicateName, + LastLogicRequired }; -// 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID struct LogicEditorResult { - bool succeeded = false; // 操作是否成功 - LogicEditorError error = LogicEditorError::None; // 失败时的分类 - std::string message; // 面向用户的 UTF-8 成功说明或失败原因 - std::string id; // 成功时返回新建或更新对象的稳定 ID + bool succeeded = false; + LogicEditorError error = LogicEditorError::None; + std::string message; + std::string id; }; -enum class LogicConditionPasteTargetKind +struct LogicSelectionDeleteRequest { - Append, - EmptyColumn, - BranchEmptyColumn, - AfterNode, - ReplaceWire, - ReplaceWireColumn, - ReplaceGapColumn + std::vector> cells; + std::vector outputRungIds; + std::vector verticalConnectionIds; }; -struct LogicConditionPasteTarget +enum class LogicClipboardMode { - LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append; - std::string expressionId; + GridObjects, + WholeRows +}; + +struct LogicClipboardCell +{ + int relativeRow = 0; + int relativeColumn = 0; + LadderCellKind kind = LadderCellKind::Gap; + std::optional node; +}; + +struct LogicClipboardOutput +{ + int relativeRow = 0; + int relativeColumn = 0; + LogicNode node; +}; + +struct LogicClipboardVerticalConnection +{ + int upperRelativeRow = 0; + int relativeColumnBoundary = 0; +}; + +struct LogicClipboardRow +{ + std::string comment; + std::vector cells; + std::optional output; +}; + +// 普通片段只保存选中的对象,整行片段才保存 Gap 和网络注释 +struct LogicClipboardFragment +{ + LogicClipboardMode mode = LogicClipboardMode::GridObjects; + std::vector cells; + std::vector outputs; + std::vector verticalConnections; + std::vector rows; + int rowSpan = 0; + int columnSpan = 0; +}; + +struct LogicSelectionCopyRequest +{ + std::vector> cells; + std::vector outputRungIds; + std::vector verticalConnectionIds; + std::vector wholeRungIds; +}; + +struct LogicClipboardCopyResult +{ + LogicEditorResult copy; + LogicClipboardFragment fragment; +}; + +struct LogicPasteTarget +{ + std::string rungId; int column = 0; + bool output = false; + bool boundary = false; }; -// 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 +struct LogicClipboardPasteResult +{ + LogicEditorResult edit; + LogicSelectionDeleteRequest selection; + std::vector wholeRungIds; +}; + +struct LogicEditCursor +{ + std::string rungId; + int column = 0; + bool output = false; +}; + +struct LogicEditResult +{ + LogicEditorResult edit; + LogicEditCursor nextCursor; +}; + +struct LogicSyntaxLocation +{ + std::string logicId; + std::string rungId; + int network = 0; + int row = 0; + int column = 0; +}; + +struct LogicSyntaxCheckResult +{ + bool completed = false; + bool valid = false; + bool changed = false; + std::size_t checkedLogicCount = 0U; + std::size_t removedWireCells = 0U; + std::size_t removedVerticalConnections = 0U; + std::string message; + std::optional location; +}; + +// 连续网格的所有修改都经此服务原子提交,并进入同一份撤销历史 class LogicEditorService { public: - /** - * @brief 创建梯形图编辑服务 - * @param project_service 用于读取和修改当前工程的项目服务 - */ explicit LogicEditorService(ProjectService &project_service); - // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 - /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ const ControlLogic *findLogic(const std::string &logic_id) const; - /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ const LadderRung *findRung( const std::string &logic_id, const std::string &rung_id) const; - /** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */ - const LogicNode *findNode( - const std::string &logic_id, const std::string &node_id) const; - /** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */ - const ConditionExpression *findExpression( + const LadderCell *findCell( + const std::string &logic_id, + const std::string &rung_id, + int column) const; + const LadderCell *findCell( const std::string &logic_id, const std::string &rung_id, - const std::string &expression_id) const; - /** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */ + const std::string &cell_id) const; + const VerticalConnection *findConnection( + const std::string &logic_id, + const std::string &connection_id) const; + const LogicNode *findNode( + const std::string &logic_id, const std::string &node_id) const; std::string firstLogicId() const; - /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ std::string firstRungId(const std::string &logic_id) const; - /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ std::string rungIdForNode( const std::string &logic_id, const std::string &node_id) const; - /** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */ std::string registerCommentFor(const RegisterAddress &address) const; - /** - * @brief 确保工程至少有一组可编辑逻辑 - * @return 已有或新建逻辑的成功结果及其 ID - * - * 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络 - */ LogicEditorResult ensureDefaultLogic(); - /** @brief 添加控制逻辑;名称不能为空且必须唯一 */ LogicEditorResult addLogic(const std::string &name); - /** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */ LogicEditorResult renameLogic( const std::string &logic_id, const std::string &name); - /** @brief 删除控制逻辑;工程至少保留一组逻辑 */ LogicEditorResult removeLogic(const std::string &logic_id); - /** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */ LogicEditorResult moveLogic(const std::string &logic_id, int offset); - /** @brief 启用或停用指定控制逻辑 */ LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); - /** @brief 在指定逻辑末尾添加空网络 */ + + /** 规整并检查指定控制逻辑,规整改动作为一次撤销操作 */ + LogicSyntaxCheckResult checkSyntax(const std::string &logic_id); + /** 单独检查指定控制逻辑中的重复 M 线圈输出 */ + LogicSyntaxCheckResult checkDoubleCoils(const std::string &logic_id) const; + /** 运行前规整并检查工程中全部已启用控制逻辑 */ + LogicSyntaxCheckResult checkEnabledSyntax(); + LogicEditorResult addRung(const std::string &logic_id); - /** @brief 删除指定网络 */ + LogicEditorResult insertRung( + const std::string &logic_id, + const std::string &reference_rung_id, + bool after); LogicEditorResult removeRung( const std::string &logic_id, const std::string &rung_id); - /** @brief 批量删除指定网络,失败时整体回滚 */ LogicEditorResult removeRungs( const std::string &logic_id, const std::vector &rung_ids); - /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ LogicEditorResult updateRungComment( const std::string &logic_id, const std::string &rung_id, const std::string &comment); - // 条件区编辑:串联、按列插入、横线和并联分支 - /** @brief 在网络条件末尾追加一个条件节点 */ - LogicEditorResult appendCondition( + + LogicEditorResult setConditionAtColumn( const std::string &logic_id, const std::string &rung_id, + int column, + const LogicNodeConfig &config, + bool configured = false); + LogicEditResult applyConditionAndAdvance( + const std::string &logic_id, + const LogicEditCursor &cursor, const LogicNodeConfig &config, bool configured = false); - /** - * @brief 按绝对条件列插入条件节点 - * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 - */ LogicEditorResult insertConditionAtColumn( const std::string &logic_id, const std::string &rung_id, int column, const LogicNodeConfig &config, bool configured = false); - /** - * @brief 在直属并联分支的指定视觉列插入条件节点 - * - * 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线 - */ - LogicEditorResult insertConditionInBranchAtColumn( + LogicEditorResult appendCondition( const std::string &logic_id, const std::string &rung_id, - const std::string &branch_expression_id, - int column, const LogicNodeConfig &config, bool configured = false); - /** @brief 在网络条件末尾追加指定列宽的横线 */ + LogicEditorResult setHorizontalWireRange( + const std::string &logic_id, + const std::string &rung_id, + int first_column, + int last_column, + bool connected); LogicEditorResult appendWire( const std::string &logic_id, const std::string &rung_id, int column_span = 1); - /** @brief 在目标节点后串联插入条件节点 */ - LogicEditorResult insertConditionAfter( + LogicEditResult applyWireAndAdvance( const std::string &logic_id, - const std::string &rung_id, - const std::string &target_node_id, - const LogicNodeConfig &config); - /** @brief 在目标表达式后串联插入横线 */ - LogicEditorResult insertWireAfter( + const LogicEditCursor &cursor); + LogicEditorResult setWireCells( const std::string &logic_id, - const std::string &rung_id, - const std::string &target_expression_id, - int column_span = 1); - /** @brief 用条件节点整体替换一条横线表达式 */ - LogicEditorResult replaceWireWithCondition( + const std::vector> &cells, + bool connected); + LogicEditorResult clearCells( const std::string &logic_id, - const std::string &rung_id, - const std::string &wire_expression_id, - const LogicNodeConfig &config); - /** @brief 只替换横线表达式中的一个指定列单元格 */ - LogicEditorResult replaceWireColumnWithCondition( + const std::vector> &cells); + LogicEditorResult setVerticalConnection( const std::string &logic_id, - const std::string &rung_id, - const std::string &wire_expression_id, - int column_offset, - const LogicNodeConfig &config, - bool configured = false); - /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ - LogicEditorResult replaceGapColumnWithCondition( + const std::string &upper_rung_id, + const std::string &lower_rung_id, + int column_boundary, + bool connected); + LogicEditorResult setVerticalConnectionRange( const std::string &logic_id, - const std::string &rung_id, - const std::string &gap_expression_id, - int column_offset, - const LogicNodeConfig &config, - bool configured = false); - /** @brief 用一格横线修复断路表达式中的指定网格 */ - LogicEditorResult replaceGapColumnWithWire( + const std::string &first_rung_id, + const std::string &last_rung_id, + int column_boundary, + bool connected); + LogicEditorResult removeVerticalConnections( const std::string &logic_id, - const std::string &rung_id, - const std::string &gap_expression_id, - int column_offset); - /** - * @brief 将选中的条件节点建立为并联分支 - * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID - */ + const std::vector &connection_ids); + LogicEditorResult deleteSelection( + const std::string &logic_id, + const LogicSelectionDeleteRequest &selection); + LogicEditorResult addParallelBranch( const std::string &logic_id, const std::string &rung_id, const std::vector &selected_node_ids, const LogicNodeConfig &config, bool configured = false); - /** @brief 将当前网络的完整条件表达式与一个新条件并联 */ LogicEditorResult addParallelToWholeCondition( const std::string &logic_id, const std::string &rung_id, const LogicNodeConfig &config, bool configured = false); - /** @brief 将选中的横线表达式建立为并联旁路 */ - LogicEditorResult addParallelWireBranch( - const std::string &logic_id, - const std::string &rung_id, - const std::vector &selected_expression_ids); - /** - * @brief 按选中的横线网格建立并联旁路 - * - * 所有网格必须来自同一条横线,且列号连续;操作失败时不保留部分修改 - */ - LogicEditorResult addParallelWireBranchAtCells( - const std::string &logic_id, - const std::string &rung_id, - const std::vector> &selected_wire_cells); - /** - * @brief 设置网络唯一的输出节点 - * @param configured 是否将新输出标记为已完成配置 - * - * 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑 - */ LogicEditorResult setOutput( const std::string &logic_id, const std::string &rung_id, const LogicNodeConfig &config, bool configured = false); - // 节点属性和删除操作 - /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ + LogicEditResult applyOutputAndAdvance( + const std::string &logic_id, + const LogicEditCursor &cursor, + const LogicNodeConfig &config, + bool configured = false); LogicEditorResult updateNodeConfig( const std::string &logic_id, const std::string &node_id, const LogicNodeConfig &config); - /** - * @brief 批量粘贴条件节点到目标网络末尾 - * @param logic_id 目标控制逻辑 - * @param rung_id 目标网络;为空时自动创建网络 - * @param nodes 待复制节点,只读取配置和 configured 状态 - * @return 成功时返回第一个新节点 ID,失败时整批回滚 - */ + LogicClipboardCopyResult copySelection( + const std::string &logic_id, + const LogicSelectionCopyRequest &selection) const; + LogicClipboardPasteResult pasteClipboard( + const std::string &logic_id, + const LogicClipboardFragment &fragment, + const LogicPasteTarget &target); LogicEditorResult pasteConditionNodes( const std::string &logic_id, const std::string &rung_id, const std::vector &nodes, - const LogicConditionPasteTarget &target = {}); - /** 判断所选条件是否位于同一串联层级并且视觉连续 */ + int start_column = -1); bool areConditionNodesContiguous( const std::string &logic_id, const std::string &rung_id, const std::vector &node_ids) const; - /** - * @brief 将整条网络复制到目标控制逻辑末尾 - * @param logic_id 目标控制逻辑 - * @param source 要复制的网络 - * @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID - */ LogicEditorResult pasteRung( const std::string &logic_id, const LadderRung &source); - /** @brief 删除指定节点并归一化受影响的表达式树 */ LogicEditorResult removeNode( const std::string &logic_id, const std::string &node_id); - /** - * @brief 批量删除节点 - * @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID - * @return 成功时作为一次编辑记录,失败时整体回滚 - */ LogicEditorResult removeNodes( const std::string &logic_id, const std::vector &node_ids); - /** @brief 删除指定条件表达式并归一化表达式树 */ - LogicEditorResult removeExpression( - const std::string &logic_id, - const std::string &rung_id, - const std::string &expression_id); - /** @brief 批量删除条件表达式,失败时整体回滚 */ - LogicEditorResult removeExpressions( - const std::string &logic_id, - const std::string &rung_id, - const std::vector &expression_ids); - /** @brief 把选中的横线网格替换为断路,保留网络列位置 */ - LogicEditorResult disconnectWireCells( - const std::string &logic_id, - const std::string &rung_id, - const std::vector> &wire_cells); - /** @brief 把整段横线替换为等宽断路 */ - LogicEditorResult disconnectWires( - const std::string &logic_id, - const std::string &rung_id, - const std::vector &wire_ids); - - // 当前编辑会话的撤销/重做 - /** @brief 判断是否存在可撤销的梯形图编辑操作 */ bool canUndo() const; - /** @brief 判断是否存在可重做的梯形图编辑操作 */ bool canRedo() const; - /** @brief 撤销最近一次成功的梯形图编辑操作 */ LogicEditorResult undo(); - /** @brief 重做最近一次被撤销的梯形图编辑操作 */ LogicEditorResult redo(); - /** @brief 清空撤销/重做历史,不修改当前工程内容 */ void clearHistory(); private: - // 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史 struct HistoryState { - std::vector logics; // 编辑前或编辑后的完整逻辑集合 + std::vector logics; }; - // 捕获当前工程中的全部控制逻辑 HistoryState captureState() const; - // 比较编辑前后状态并记录实际发生的修改 void recordHistory(HistoryState before); - // 失败时恢复工程内容和操作前脏标记,保证编辑原子性 void rollbackEdit(HistoryState before, bool modified_before); - // 比较两个历史快照是否完全相同 static bool statesEqual( const HistoryState &left, const HistoryState &right); static bool logicsEqual( const ControlLogic &left, const ControlLogic &right); static bool rungsEqual( const LadderRung &left, const LadderRung &right); - static bool expressionsEqual( - const ConditionExpression &left, const ConditionExpression &right); + static bool cellsEqual( + const LadderCell &left, const LadderCell &right); static bool nodesEqual(const LogicNode &left, const LogicNode &right); static bool configsEqual( const LogicNodeConfig &left, const LogicNodeConfig &right); static LogicEditorResult historyFailure(const std::string &message); - - // 判断节点配置是否属于条件节点 static bool isConditionConfig(const LogicNodeConfig &config); - // 判断节点配置是否属于输出节点 static bool isOutputConfig(const LogicNodeConfig &config); - // 返回节点配置对应的稳定 ID 前缀 static std::string nodePrefix(const LogicNodeConfig &config); - // 在一个控制逻辑的全部网络中生成唯一节点 ID - static std::string makeUniqueNodeId( + static std::string makeUniqueId( const ControlLogic &logic, const std::string &prefix); - // 生成唯一横线表达式 ID - static std::string makeUniqueWireId(const ControlLogic &logic); - // 生成控制逻辑内唯一的断路表达式 ID - static std::string makeUniqueGapId(const ControlLogic &logic); - // 生成唯一容器表达式 ID - static std::string makeUniqueExpressionId(const ControlLogic &logic); - // 生成唯一网络 ID - static std::string makeUniqueRungId(const ControlLogic &logic); - // 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局 - static bool repairExplicitLayout( - ControlLogic &logic, LadderRung &rung, std::string *error = nullptr); - // 创建统一的失败结果 + static LadderRung makeEmptyRung( + const ControlLogic &logic, std::size_t visual_index); + static std::string insertEmptyRungAt( + ControlLogic *logic, std::size_t position); + static void removeRungAt(ControlLogic *logic, std::size_t position); + static void refreshRungNames(ControlLogic *logic); static LogicEditorResult failure( LogicEditorError error, const std::string &message); + LogicEditResult applyCellAndAdvance( + const std::string &logic_id, + const LogicEditCursor &cursor, + const LogicNodeConfig *config, + bool configured); + LogicSyntaxCheckResult checkSyntaxForLogics( + const std::vector &logic_ids); - ProjectService &project_service_; // 不拥有的工程服务依赖 - EditorHistory history_; // 当前编辑会话的撤销/重做历史 - bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史 + ProjectService &project_service_; + EditorHistory history_; }; diff --git a/app/src/services/online_logic_monitor_service.cpp b/app/src/services/online_logic_monitor_service.cpp index f3bd659..a90bab9 100644 --- a/app/src/services/online_logic_monitor_service.cpp +++ b/app/src/services/online_logic_monitor_service.cpp @@ -59,10 +59,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( for (const LadderRung &rung : logic.rungs) { std::vector nodes; - if (rung.condition.has_value()) - { - collectConditionNodes(*rung.condition, &nodes); - } + collectConditionNodes(rung, &nodes); if (rung.output.has_value()) { nodes.push_back(&*rung.output); diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index a1f9a15..668479a 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -202,7 +202,7 @@ Project ProjectService::makeNewProject(const std::string &name) project.metadata.id = generateProjectId(); project.metadata.name = name; // 新工程固定使用当前存储格式版本 - project.metadata.formatVersion = "1.0"; + project.metadata.formatVersion = "2.0"; return project; } diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 9df711c..9ed8594 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -13,12 +13,15 @@ #include "domain/project_limits.h" #include +#include RuntimeModeService::RuntimeModeService( const ProjectService &project_service, + LogicEditorService &logic_editor_service, OfflineSimulationService &offline_simulation_service, OnlineLogicMonitorService &online_logic_monitor_service) : project_service_(project_service), + logic_editor_service_(logic_editor_service), offline_simulation_service_(offline_simulation_service), online_logic_monitor_service_(online_logic_monitor_service) { @@ -68,11 +71,10 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() { return state_.enterOfflineRunning(); } - std::string error; - if (!project_service_.project().validateForRunning( - project_service_.projectLimits(), &error)) + const ModeTransitionResult preparation = prepareProjectForRunning(); + if (!preparation.succeeded) { - return {false, ModeTransitionError::ProjectNotReady}; + return preparation; } // 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存 if (active_repository_ != nullptr && virtual_repository_ != nullptr) @@ -84,7 +86,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() project_service_.project().controlLogics); if (!start_result.succeeded) { - return {false, ModeTransitionError::SimulationStartFailed}; + return {false, ModeTransitionError::SimulationStartFailed, {}}; } const ModeTransitionResult transition = state_.enterOfflineRunning(); if (!transition.succeeded) @@ -104,12 +106,17 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning() { return state_.enterOnlineRunning(false); } + const ModeTransitionResult preparation = prepareProjectForRunning(); + if (!preparation.succeeded) + { + return preparation; + } const OnlineLogicMonitorStartResult start_result = online_logic_monitor_service_.start( project_service_.project().controlLogics); if (!start_result.succeeded) { - return {false, ModeTransitionError::SimulationStartFailed}; + return {false, ModeTransitionError::SimulationStartFailed, {}}; } const ModeTransitionResult result = state_.enterOnlineRunning(true); if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) @@ -162,6 +169,30 @@ OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService() return online_logic_monitor_service_; } +const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const +{ + return last_syntax_check_; +} + +ModeTransitionResult RuntimeModeService::prepareProjectForRunning() +{ + last_syntax_check_ = logic_editor_service_.checkEnabledSyntax(); + if (!last_syntax_check_.completed || !last_syntax_check_.valid) + { + return { + false, + ModeTransitionError::ProjectNotReady, + last_syntax_check_.message}; + } + std::string error; + if (!project_service_.project().validateForRunning( + project_service_.projectLimits(), &error)) + { + return {false, ModeTransitionError::ProjectNotReady, std::move(error)}; + } + return {true, ModeTransitionError::None, {}}; +} + void RuntimeModeService::configurePlc( PlcCommunicationGateway &gateway, ActiveRegisterRepository &active_repository, @@ -304,10 +335,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() for (const LadderRung &rung : logic.rungs) { std::vector nodes; - if (rung.condition.has_value()) - { - collectConditionNodes(*rung.condition, &nodes); - } + collectConditionNodes(rung, &nodes); if (rung.output.has_value()) { nodes.push_back(&*rung.output); diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index aaa1ebd..aa9eb43 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -9,6 +9,7 @@ #pragma once #include "domain/runtime_state.h" +#include "logic_editor_service.h" #include "offline_simulation_service.h" #include "online_logic_monitor_service.h" #include "plc_communication_gateway.h" @@ -32,6 +33,7 @@ public: */ RuntimeModeService( const ProjectService &project_service, + LogicEditorService &logic_editor_service, OfflineSimulationService &offline_simulation_service, OnlineLogicMonitorService &online_logic_monitor_service); @@ -86,6 +88,8 @@ public: /** @brief 返回服务持有的离线仿真服务引用 */ OfflineSimulationService &offlineSimulationService(); OnlineLogicMonitorService &onlineLogicMonitorService(); + /** 返回最近一次进入运行模式前执行的梯形图语法检查结果 */ + const LogicSyntaxCheckResult &lastSyntaxCheck() const; /** * @brief 注入 PLC 网关、活动仓库和两种实际数据源 @@ -137,7 +141,10 @@ public: void setPlcStatusChangedCallback(std::function callback); private: + ModeTransitionResult prepareProjectForRunning(); + const ProjectService &project_service_; // 不拥有的只读工程服务 + LogicEditorService &logic_editor_service_; // 统一执行运行前规整和语法检查 OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务 RuntimeState state_; // 编辑、离线和真机模式状态机 @@ -150,4 +157,5 @@ private: std::function plc_status_changed_callback_; // PLC 状态变化通知 std::vector monitor_addresses_; // 自由监控额外引用的地址 std::vector monitor_float32_starts_; // 自由监控中的 Float32 起始地址 + LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 }; diff --git a/app/src/services/software_logic_executor.cpp b/app/src/services/software_logic_executor.cpp index bf66484..843ae08 100644 --- a/app/src/services/software_logic_executor.cpp +++ b/app/src/services/software_logic_executor.cpp @@ -75,9 +75,10 @@ void LogicTraceValues::clear() { nodeValues.clear(); nodePowerValues.clear(); - expressionValues.clear(); - expressionInputValues.clear(); - expressionPowerValues.clear(); + cellValues.clear(); + cellInputPowerValues.clear(); + cellPowerValues.clear(); + verticalConnectionValues.clear(); rungValues.clear(); wordValues.clear(); } @@ -97,9 +98,11 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( { projection.nodeValues = values->second.nodeValues; projection.nodePowerValues = values->second.nodePowerValues; - projection.expressionValues = values->second.expressionValues; - projection.expressionInputValues = values->second.expressionInputValues; - projection.expressionPowerValues = values->second.expressionPowerValues; + projection.cellValues = values->second.cellValues; + projection.cellInputPowerValues = values->second.cellInputPowerValues; + projection.cellPowerValues = values->second.cellPowerValues; + projection.verticalConnectionValues = + values->second.verticalConnectionValues; projection.rungValues = values->second.rungValues; projection.wordValues = values->second.wordValues; } @@ -192,7 +195,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( { trace->clear(); } - // 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见 + // 互相没有竖线连接的行仍按先后顺序扫描,保持原有网络间可见性 for (const ControlLogic &logic : logics) { if (!logic.enabled) @@ -201,56 +204,195 @@ LogicScanResult SoftwareLogicExecutor::executeScan( } LogicTraceValues *logic_trace = trace == nullptr ? nullptr : &trace->logicValues[logic.id]; - for (const LadderRung &rung : logic.rungs) + std::size_t group_start = 0U; + while (group_start < logic.rungs.size()) { - if (!rung.condition.has_value() && !rung.output.has_value()) + std::size_t group_end = group_start; + while (group_end + 1U < logic.rungs.size()) { - continue; + const std::string &upper_id = logic.rungs[group_end].id; + const std::string &lower_id = logic.rungs[group_end + 1U].id; + const bool connected = std::any_of( + logic.verticalConnections.cbegin(), + logic.verticalConnections.cend(), + [&upper_id, &lower_id](const VerticalConnection &connection) + { + return connection.upperRungId == upper_id + && connection.lowerRungId == lower_id; + }); + if (!connected) + { + break; + } + ++group_end; } - // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 - bool rung_value = true; - LogicScanResult result = success(); - if (rung.condition.has_value()) - { - result = evaluateExpression( - logic.id, - *rung.condition, - repository, - logic_trace, - true, - &rung_value); - } - if (!result.succeeded) - { - result.logicId = logic.id; - result.rungId = rung.id; - return result; - } - if (logic_trace != nullptr) + const std::size_t group_size = group_end - group_start + 1U; + std::vector power(group_size, true); + for (int boundary = 0; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) { - logic_trace->rungValues[rung.id] = rung_value; - logic_trace->nodeValues[rung.output->id] = rung_value; - logic_trace->nodePowerValues[rung.output->id] = rung_value; - } - bool output_value = false; - result = executeOutput( - *rung.output, - rung_value, - repository, - logic_trace, - &output_value); - if (!result.succeeded) - { - result.logicId = logic.id; - result.rungId = rung.id; - return result; + std::vector parent(group_size); + for (std::size_t index = 0U; index < group_size; ++index) + { + parent[index] = index; + } + const auto root = [&parent](std::size_t index) + { + while (parent[index] != index) + { + index = parent[index]; + } + return index; + }; + const auto unite = [&parent, &root]( + std::size_t left, std::size_t right) + { + const std::size_t left_root = root(left); + const std::size_t right_root = root(right); + if (left_root != right_root) + { + parent[right_root] = left_root; + } + }; + + for (const VerticalConnection &connection + : logic.verticalConnections) + { + if (connection.columnBoundary != boundary) + { + continue; + } + for (std::size_t row = group_start; + row < group_end; + ++row) + { + if (logic.rungs[row].id == connection.upperRungId + && logic.rungs[row + 1U].id + == connection.lowerRungId) + { + unite(row - group_start, row + 1U - group_start); + break; + } + } + } + + std::vector component_power(group_size, false); + for (std::size_t index = 0U; index < group_size; ++index) + { + component_power[root(index)] = + component_power[root(index)] || power[index]; + } + for (std::size_t index = 0U; index < group_size; ++index) + { + power[index] = component_power[root(index)]; + } + if (logic_trace != nullptr) + { + for (const VerticalConnection &connection + : logic.verticalConnections) + { + if (connection.columnBoundary != boundary) + { + continue; + } + for (std::size_t row = group_start; + row < group_end; + ++row) + { + if (logic.rungs[row].id == connection.upperRungId + && logic.rungs[row + 1U].id + == connection.lowerRungId) + { + logic_trace->verticalConnectionValues[ + connection.id] = power[row - group_start]; + break; + } + } + } + } + if (boundary == ProjectLimits::kMaximumConditionColumns) + { + break; + } + + for (std::size_t local_row = 0U; + local_row < group_size; + ++local_row) + { + const LadderRung &rung = + logic.rungs[group_start + local_row]; + const LadderCell &cell = rung.cells[ + static_cast(boundary)]; + const bool input_power = power[local_row]; + bool cell_value = cell.kind == LadderCellKind::Wire; + LogicScanResult result = success(); + if (cell.kind == LadderCellKind::Node) + { + result = evaluateCondition( + logic.id, + *cell.node, + repository, + &cell_value); + } + if (!result.succeeded) + { + result.logicId = logic.id; + result.rungId = rung.id; + return result; + } + power[local_row] = input_power && cell_value; + if (logic_trace != nullptr) + { + logic_trace->cellValues[cell.id] = cell_value; + logic_trace->cellInputPowerValues[cell.id] = input_power; + logic_trace->cellPowerValues[cell.id] = power[local_row]; + if (cell.node.has_value()) + { + logic_trace->nodeValues[cell.node->id] = cell_value; + logic_trace->nodePowerValues[cell.node->id] = + power[local_row]; + } + } + } } - if (logic_trace != nullptr) + + // 同一个竖线连通组先完成条件传播,再按视觉行顺序执行输出 + for (std::size_t local_row = 0U; + local_row < group_size; + ++local_row) { - logic_trace->nodeValues[rung.output->id] = output_value; - logic_trace->nodePowerValues[rung.output->id] = output_value; + const LadderRung &rung = logic.rungs[group_start + local_row]; + const bool rung_value = power[local_row]; + if (logic_trace != nullptr) + { + logic_trace->rungValues[rung.id] = rung_value; + } + if (!rung.output.has_value()) + { + continue; + } + bool output_value = false; + LogicScanResult result = executeOutput( + *rung.output, + rung_value, + repository, + logic_trace, + &output_value); + if (!result.succeeded) + { + result.logicId = logic.id; + result.rungId = rung.id; + return result; + } + if (logic_trace != nullptr) + { + logic_trace->nodeValues[rung.output->id] = output_value; + logic_trace->nodePowerValues[rung.output->id] = rung_value; + } } + group_start = group_end + 1U; } } if (trace != nullptr) @@ -265,9 +407,11 @@ LogicScanResult SoftwareLogicExecutor::executeScan( { trace->nodeValues = values->second.nodeValues; trace->nodePowerValues = values->second.nodePowerValues; - trace->expressionValues = values->second.expressionValues; - trace->expressionInputValues = values->second.expressionInputValues; - trace->expressionPowerValues = values->second.expressionPowerValues; + trace->cellValues = values->second.cellValues; + trace->cellInputPowerValues = values->second.cellInputPowerValues; + trace->cellPowerValues = values->second.cellPowerValues; + trace->verticalConnectionValues = + values->second.verticalConnectionValues; trace->rungValues = values->second.rungValues; trace->wordValues = values->second.wordValues; } @@ -276,89 +420,6 @@ LogicScanResult SoftwareLogicExecutor::executeScan( return success(); } -LogicScanResult SoftwareLogicExecutor::evaluateExpression( - const std::string &logic_id, - const ConditionExpression &expression, - RegisterRepository &repository, - LogicTraceValues *trace, - bool input_power, - bool *value) -{ - if (value == nullptr) - { - return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象"); - } - if (trace != nullptr) - { - trace->expressionInputValues[expression.id] = input_power; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - *value = true; - if (trace != nullptr) - { - trace->expressionValues[expression.id] = true; - trace->expressionPowerValues[expression.id] = input_power; - } - return success(); - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return failure( - LogicScanError::InvalidLogic, - "梯形图条件区存在未连接的空白网格", - logic_id, - {}, - expression.id); - } - if (expression.kind == ConditionExpressionKind::Node) - { - LogicScanResult result = evaluateCondition( - logic_id, *expression.node, repository, value); - if (result.succeeded && trace != nullptr) - { - trace->nodeValues[expression.node->id] = *value; - trace->nodePowerValues[expression.node->id] = input_power && *value; - trace->expressionValues[expression.id] = *value; - trace->expressionPowerValues[expression.id] = input_power && *value; - } - return result; - } - - bool accumulated = expression.kind == ConditionExpressionKind::Series; - bool power = input_power; - for (const ConditionExpression &child : expression.children) - { - bool child_value = false; - const bool child_input = expression.kind == ConditionExpressionKind::Series - ? power : input_power; - LogicScanResult result = evaluateExpression( - logic_id, - child, - repository, - trace, - child_input, - &child_value); - if (!result.succeeded) - { - return result; - } - accumulated = expression.kind == ConditionExpressionKind::Series - ? accumulated && child_value : accumulated || child_value; - if (expression.kind == ConditionExpressionKind::Series) - { - power = power && child_value; - } - } - *value = accumulated; - if (trace != nullptr) - { - trace->expressionValues[expression.id] = accumulated; - trace->expressionPowerValues[expression.id] = input_power && accumulated; - } - return success(); -} - LogicScanResult SoftwareLogicExecutor::evaluateCondition( const std::string &logic_id, const LogicNode &node, diff --git a/app/src/services/software_logic_executor.h b/app/src/services/software_logic_executor.h index 809fea3..c8b99ad 100644 --- a/app/src/services/software_logic_executor.h +++ b/app/src/services/software_logic_executor.h @@ -38,15 +38,16 @@ struct WordTraceValue bool overflow = false; // ADD/SUB 是否发生饱和溢出 }; -// 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键 +// 一张连续梯形图的运行轨迹快照 struct LogicTraceValues { - std::unordered_map nodeValues; // 节点最终逻辑值 - std::unordered_map nodePowerValues; // 节点输入电源状态 - std::unordered_map expressionValues; // 条件表达式最终值 - std::unordered_map expressionInputValues; // 表达式输入状态 - std::unordered_map expressionPowerValues; // 表达式输出电源状态 - std::unordered_map rungValues; // 网络最终逻辑值 + std::unordered_map nodeValues; // 节点自身结果 + std::unordered_map nodePowerValues; // 节点后的带电状态 + std::unordered_map cellValues; // 网格自身是否导通 + std::unordered_map cellInputPowerValues; // 网格左边界电源 + std::unordered_map cellPowerValues; // 网格右边界电源 + std::unordered_map verticalConnectionValues; // 竖线所在边界电源 + std::unordered_map rungValues; // 行末输出槽电源 std::unordered_map wordValues; // MOVE/ADD/SUB 节点状态 // 清除当前逻辑或网络的全部轨迹 @@ -109,23 +110,6 @@ private: const LogicNode &node, RegisterRepository &repository, bool *value); - /** - * @brief 递归计算串联、并联或叶子条件表达式 - * @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算 - * @param expression 待读取的条件表达式树 - * @param repository 提供 M/D 值的寄存器仓库 - * @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出 - * @param input_power 进入当前表达式的电源状态 - * @param value 输出表达式最终值,不能为空 - * @return 成功结果,或包含失败节点 ID 的扫描错误 - */ - LogicScanResult evaluateExpression( - const std::string &logic_id, - const ConditionExpression &expression, - RegisterRepository &repository, - LogicTraceValues *trace, - bool input_power, - bool *value); /** * @brief 在网络结果驱动下执行单个输出节点 * @param node 待读取配置并执行的输出节点 diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 518af2e..9fbf9c9 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -1,16 +1,20 @@ #include "logic_editor_widget.h" #include -#include -#include +#include #include #include +#include +#include +#include +#include +#include #include #include #include #include -#include -#include +#include +#include #include #include #include @@ -24,35 +28,26 @@ namespace { -constexpr qreal kSceneMargin = 28.0; -constexpr qreal kRailInset = 40.0; -constexpr qreal kMinimumSceneWidth = 980.0; -constexpr qreal kCellWidth = 128.0; -constexpr qreal kCellHeight = 120.0; -constexpr qreal kNodeTerminalX = 50.0; -constexpr qreal kRungHeaderHeight = 52.0; -constexpr qreal kRungGap = 18.0; -constexpr qreal kLadderLineWidth = 1.8; -constexpr int kMinimumLogicColumns = 7; +constexpr qreal kLabelWidth = 60.0; +constexpr qreal kCellWidth = 96.0; +constexpr qreal kOutputWidth = 224.0; +constexpr qreal kRowHeight = 78.0; +constexpr qreal kCommentBandHeight = 28.0; +constexpr qreal kTop = 18.0; +constexpr qreal kLeftBus = kLabelWidth; +constexpr qreal kConditionRight = kLabelWidth + + ProjectLimits::kMaximumConditionColumns * kCellWidth; +constexpr qreal kRightBus = kConditionRight + kOutputWidth; +constexpr qreal kSceneRightMargin = 24.0; + +const QColor kCanvasBackground(QStringLiteral("#ffffff")); +const QColor kGridColor(QStringLiteral("#d8e0e4")); const QColor kLadderColor(QStringLiteral("#263842")); const QColor kActiveColor(QStringLiteral("#16854f")); const QColor kFaultColor(QStringLiteral("#c5362e")); -const QColor kSelectionColor(QStringLiteral("#dfeef5")); -const QColor kSelectionBorderColor(QStringLiteral("#277da1")); -const QColor kGridColor(QStringLiteral("#e8edf0")); -const QColor kPlaceholderColor(QStringLiteral("#81919b")); - -struct ExpressionMetrics -{ - int columns = 1; - int rows = 1; -}; - -struct RenderResult -{ - QPointF input; - QPointF output; -}; +const QColor kCommentColor(QStringLiteral("#16854f")); +const QColor kSelectionFill(QStringLiteral("#dfeef5")); +const QColor kSelectionBorder(QStringLiteral("#277da1")); QString registerAddressText(const RegisterAddress &address) { @@ -66,18 +61,18 @@ QString wordOperandText(const WordOperand &operand) : QString::number(operand.constant); } -QString compareMnemonic(ComparisonOperator comparison) +QString comparisonText(ComparisonOperator comparison) { switch (comparison) { - case ComparisonOperator::Equal: return QStringLiteral("LD="); - case ComparisonOperator::NotEqual: return QStringLiteral("LD<>"); - case ComparisonOperator::LessThan: return QStringLiteral("LD<"); - case ComparisonOperator::LessThanOrEqual: return QStringLiteral("LD<="); - case ComparisonOperator::GreaterThan: return QStringLiteral("LD>"); - case ComparisonOperator::GreaterThanOrEqual: return QStringLiteral("LD>="); + case ComparisonOperator::Equal: return QStringLiteral("="); + case ComparisonOperator::NotEqual: return QStringLiteral("<>"); + case ComparisonOperator::LessThan: return QStringLiteral("<"); + case ComparisonOperator::LessThanOrEqual: return QStringLiteral("<="); + case ComparisonOperator::GreaterThan: return QStringLiteral(">"); + case ComparisonOperator::GreaterThanOrEqual: return QStringLiteral(">="); } - return QStringLiteral("LD="); + return QStringLiteral("?"); } QString logicCommandText(const LogicNodeConfig &config) @@ -90,16 +85,14 @@ QString logicCommandText(const LogicNodeConfig &config) { return QStringLiteral("%1 %2") .arg(value.mode == ContactMode::NormallyOpen - ? QStringLiteral("LD") - : QStringLiteral("LDI")) + ? QStringLiteral("LD") : QStringLiteral("LDI")) .arg(registerAddressText(value.address)); } else if constexpr (std::is_same_v) { return QStringLiteral("%1 %2") .arg(value.mode == EdgeMode::Rising - ? QStringLiteral("LDP") - : QStringLiteral("LDF")) + ? QStringLiteral("LDP") : QStringLiteral("LDF")) .arg(registerAddressText(value.address)); } else if constexpr (std::is_same_v) @@ -114,8 +107,8 @@ QString logicCommandText(const LogicNodeConfig &config) } else if constexpr (std::is_same_v) { - return QStringLiteral("%1 %2 %3") - .arg(compareMnemonic(value.comparison)) + return QStringLiteral("LD%1 %2 %3") + .arg(comparisonText(value.comparison)) .arg(registerAddressText(value.address)) .arg(value.value); } @@ -138,1051 +131,529 @@ QString logicCommandText(const LogicNodeConfig &config) config); } -bool isLoadCommandOpcode(LogicCommandOpcode opcode) -{ - return opcode == LogicCommandOpcode::Load - || opcode == LogicCommandOpcode::LoadInverse - || opcode == LogicCommandOpcode::LoadRising - || opcode == LogicCommandOpcode::LoadFalling - || opcode == LogicCommandOpcode::CompareEqual - || opcode == LogicCommandOpcode::CompareNotEqual - || opcode == LogicCommandOpcode::CompareLessThan - || opcode == LogicCommandOpcode::CompareLessThanOrEqual - || opcode == LogicCommandOpcode::CompareGreaterThan - || opcode == LogicCommandOpcode::CompareGreaterThanOrEqual; -} - QString commandCompletionPrefix(const QString &text) { return text.section(QRegularExpression(QStringLiteral("\\s+")), 0, 0) .toUpper(); } -void drawRegisterComment( - QPainter *painter, const QString &comment, qreal top = 22.0) -{ - if (comment.isEmpty()) - { - return; - } - QFont font = painter->font(); - font.setPointSizeF(8.0); - painter->setFont(font); - const QString visible_comment = painter->fontMetrics().elidedText( - comment, Qt::ElideRight, static_cast(kCellWidth - 8.0)); - painter->drawText( - QRectF(-kCellWidth / 2.0, top, kCellWidth, 18), - Qt::AlignCenter | Qt::TextSingleLine, - visible_comment); -} - -QString comparisonText(ComparisonOperator comparison) -{ - switch (comparison) - { - case ComparisonOperator::Equal: return QStringLiteral("="); - case ComparisonOperator::NotEqual: return QStringLiteral("<>"); - case ComparisonOperator::LessThan: return QStringLiteral("<"); - case ComparisonOperator::LessThanOrEqual: return QStringLiteral("<="); - case ComparisonOperator::GreaterThan: return QStringLiteral(">"); - case ComparisonOperator::GreaterThanOrEqual: return QStringLiteral(">="); - } - return QStringLiteral("?"); -} - -QString nodeToolTip(const LogicNodeConfig &config) -{ - return std::visit( - [](const auto &value) -> QString - { - using Config = std::decay_t; - if constexpr (std::is_same_v) - { - return LogicEditorWidget::tr("%1触点:%2") - .arg(value.mode == ContactMode::NormallyOpen - ? LogicEditorWidget::tr("常开") - : LogicEditorWidget::tr("常闭")) - .arg(registerAddressText(value.address)); - } - else if constexpr (std::is_same_v) - { - return LogicEditorWidget::tr("%1沿触点:%2") - .arg(value.mode == EdgeMode::Rising - ? LogicEditorWidget::tr("上升") - : LogicEditorWidget::tr("下降")) - .arg(registerAddressText(value.address)); - } - else if constexpr (std::is_same_v) - { - return LogicEditorWidget::tr("输出线圈:%1") - .arg(registerAddressText(value.address)); - } - else if constexpr (std::is_same_v) - { - return LogicEditorWidget::tr("比较条件:%1 %2 %3") - .arg(registerAddressText(value.address)) - .arg(comparisonText(value.comparison)) - .arg(value.value); - } - else if constexpr (std::is_same_v) - { - return LogicEditorWidget::tr("MOVE:%1 -> %2") - .arg(wordOperandText(value.source)) - .arg(registerAddressText(value.destination)); - } - else - { - return LogicEditorWidget::tr("%1:%2,%3 -> %4") - .arg(value.operation == ArithmeticOperation::Add - ? QStringLiteral("ADD") : QStringLiteral("SUB")) - .arg(wordOperandText(value.left)) - .arg(wordOperandText(value.right)) - .arg(registerAddressText(value.destination)); - } - }, - config); -} - -ExpressionMetrics measureExpression(const ConditionExpression &expression) -{ - if (expression.kind == ConditionExpressionKind::Node) - { - return {}; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - return {expression.wire->columnSpan, 1}; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return {expression.gap->columnSpan, 1}; - } - - ExpressionMetrics metrics{0, 0}; - if (expression.kind == ConditionExpressionKind::Series) - { - for (const ConditionExpression &child : expression.children) - { - const ExpressionMetrics child_metrics = measureExpression(child); - metrics.columns += child_metrics.columns; - metrics.rows = std::max(metrics.rows, child_metrics.rows); - } - } - else - { - for (const ConditionExpression &child : expression.children) - { - const ExpressionMetrics child_metrics = measureExpression(child); - metrics.columns = std::max(metrics.columns, child_metrics.columns); - metrics.rows += child_metrics.rows; - } - } - return metrics; -} - -QPen ladderPen(bool active) +QPen ladderPen(bool active, bool faulted = false) { - return QPen(active ? kActiveColor : kLadderColor, - active ? 2.6 : kLadderLineWidth); + QPen pen(faulted ? kFaultColor : active ? kActiveColor : kLadderColor); + pen.setWidthF(active || faulted ? 2.5 : 1.8); + pen.setJoinStyle(Qt::MiterJoin); + pen.setCapStyle(Qt::SquareCap); + return pen; } -bool traceValue( - const LogicTraceSnapshot &trace, - const std::unordered_map LogicTraceSnapshot::*member, - const std::string &id) +bool containsId( + const std::vector &ids, const std::string &candidate) { - const auto &values = trace.*member; - const auto found = values.find(id); - return found != values.end() && found->second; + return std::find(ids.cbegin(), ids.cend(), candidate) != ids.cend(); } -void addGrid( - QGraphicsScene &scene, - qreal left, - qreal top, - int columns, - int rows) +bool containsCell( + const std::vector> &cells, + const std::pair &candidate) { - QPen pen(kGridColor, 1.0); - pen.setCosmetic(true); - for (int column = 0; column <= columns; ++column) - { - const qreal x = left + static_cast(column) * kCellWidth; - QGraphicsLineItem *line = scene.addLine( - QLineF(x, top, x, top + static_cast(rows) * kCellHeight), pen); - line->setZValue(-10.0); - } - for (int row = 0; row <= rows; ++row) - { - const qreal y = top + static_cast(row) * kCellHeight; - QGraphicsLineItem *line = scene.addLine( - QLineF(left, y, left + static_cast(columns) * kCellWidth, y), pen); - line->setZValue(-10.0); - } + return std::find(cells.cbegin(), cells.cend(), candidate) != cells.cend(); } -void addLineIfVisible( - QGraphicsScene &scene, const QLineF &line, const QPen &pen) +struct GridRow { - if (line.length() <= 0.01) - { - return; - } - scene.addLine(line, pen); -} - -} // namespace + qreal top = 0.0; +}; -class LogicEditorWidget::NodeItem final : public QGraphicsItem +class GridLayerItem final : public QGraphicsItem { public: - NodeItem( - const LogicNode &node, - const std::string &rung_id, - const QPointF ¢er, - const QString ®ister_comment, - bool active, - bool faulted, - bool condition) - : node_id_(node.id), - rung_id_(rung_id), - config_(node.config), - register_comment_(register_comment), - configured_(node.configured), - active_(active), - faulted_(faulted), - condition_(condition) - { - setPos(center); - setFlag(ItemIsSelectable, true); - setZValue(3.0); - QString tool_tip = configured_ - ? nodeToolTip(config_) - : LogicEditorWidget::tr("待配置:请在属性区设置地址和参数"); - if (!register_comment_.isEmpty()) - { - tool_tip += LogicEditorWidget::tr("\n注释:%1").arg(register_comment_); - } - setToolTip(tool_tip); - } - - QRectF boundingRect() const override + explicit GridLayerItem(std::vector rows) + : rows_(std::move(rows)) { - return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; + setZValue(0.0); } - void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) override + QRectF boundingRect() const override { - painter->setRenderHint(QPainter::Antialiasing, true); - const bool selected = (option->state & QStyle::State_Selected) != 0; - if (selected) + if (rows_.empty()) { - painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor); - painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); - painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); + return {}; } - - const QColor symbol_color = faulted_ ? kFaultColor - : selected ? kSelectionBorderColor : active_ ? kActiveColor : kLadderColor; - painter->setPen(QPen(symbol_color, active_ || faulted_ ? 2.6 : kLadderLineWidth)); - QFont font = painter->font(); - font.setPointSizeF(9.5); - painter->setFont(font); - - if (const auto *contact = std::get_if(&config_)) - { - painter->fillRect(QRectF(-25, -22, 50, 44), Qt::white); - painter->drawLine(QPointF(-kNodeTerminalX, 0), QPointF(-18, 0)); - painter->drawLine(QPointF(18, 0), QPointF(kNodeTerminalX, 0)); - painter->drawLine(QPointF(-18, -15), QPointF(-18, 15)); - painter->drawLine(QPointF(18, -15), QPointF(18, 15)); - if (contact->mode == ContactMode::NormallyClosed) - { - painter->drawLine(QPointF(-23, 18), QPointF(23, -18)); - } - painter->drawText( - QRectF(-kCellWidth / 2.0, -40, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? registerAddressText(contact->address) : tr("< M 地址 >")); - drawRegisterComment(painter, register_comment_); - } - else if (const auto *edge = std::get_if(&config_)) - { - painter->fillRect(QRectF(-25, -22, 50, 44), Qt::white); - painter->drawLine(QPointF(-kNodeTerminalX, 0), QPointF(-18, 0)); - painter->drawLine(QPointF(18, 0), QPointF(kNodeTerminalX, 0)); - painter->drawLine(QPointF(-18, -15), QPointF(-18, 15)); - painter->drawLine(QPointF(18, -15), QPointF(18, 15)); - painter->drawText( - QRectF(-14, -12, 28, 24), - Qt::AlignCenter, - edge->mode == EdgeMode::Rising ? QStringLiteral("P") : QStringLiteral("N")); - painter->drawText( - QRectF(-kCellWidth / 2.0, -40, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? registerAddressText(edge->address) : tr("< M 地址 >")); - drawRegisterComment(painter, register_comment_); - } - else if (const auto *coil = std::get_if(&config_)) - { - painter->fillRect(QRectF(-35, -23, 70, 46), Qt::white); - painter->drawLine(QPointF(-kNodeTerminalX, 0), QPointF(-16, 0)); - painter->drawLine(QPointF(16, 0), QPointF(kNodeTerminalX, 0)); - QPainterPath left; - left.moveTo(-2, -20); - left.cubicTo(-24, -16, -24, 16, -2, 20); - painter->drawPath(left); - QPainterPath right; - right.moveTo(2, -20); - right.cubicTo(24, -16, 24, 16, 2, 20); - painter->drawPath(right); - if (coil->mode != CoilMode::Normal) + return { + kLeftBus, + rows_.front().top, + kRightBus - kLeftBus, + rows_.back().top + kRowHeight - rows_.front().top}; + } + + void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override + { + painter->setRenderHint(QPainter::Antialiasing, false); + QPen grid_pen(kGridColor); + grid_pen.setWidthF(1.0); + grid_pen.setCosmetic(true); + painter->setPen(grid_pen); + for (const GridRow &row : rows_) + { + painter->fillRect( + QRectF( + kLeftBus, + row.top, + kRightBus - kLeftBus, + kRowHeight), + kCanvasBackground); + painter->drawRect( + QRectF( + kLeftBus, + row.top, + kRightBus - kLeftBus, + kRowHeight)); + for (int boundary = 1; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) { - painter->drawText( - QRectF(-12, -14, 24, 28), - Qt::AlignCenter, - coil->mode == CoilMode::Set ? QStringLiteral("S") : QStringLiteral("R")); + const qreal x = kLeftBus + + static_cast(boundary) * kCellWidth; + painter->drawLine( + QPointF(x, row.top), + QPointF(x, row.top + kRowHeight)); } - painter->drawText( - QRectF(-kCellWidth / 2.0, -40, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? registerAddressText(coil->address) : tr("< M 地址 >")); - drawRegisterComment(painter, register_comment_); - } - else if (const auto *comparison = std::get_if(&config_)) - { - const QRectF box(-47, -17, 94, 34); - painter->fillRect(box.adjusted(-1, -1, 1, 1), Qt::white); - painter->drawLine(QPointF(-kNodeTerminalX, 0), QPointF(-47, 0)); - painter->drawLine(QPointF(47, 0), QPointF(kNodeTerminalX, 0)); - painter->drawRect(box); - painter->drawText( - box, Qt::AlignCenter, - QStringLiteral("%1 INT").arg(comparisonText(comparison->comparison))); - painter->drawText( - QRectF(-kCellWidth / 2.0, -40, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? registerAddressText(comparison->address) : tr("< D 地址 >")); - painter->drawText( - QRectF(-kCellWidth / 2.0, 22, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? QString::number(comparison->value) : tr("< 常量 >")); - drawRegisterComment(painter, register_comment_, 40.0); - } - else if (const auto *move = std::get_if(&config_)) - { - const QRectF box(-47, -18, 94, 36); - painter->fillRect(box.adjusted(-1, -1, 1, 1), Qt::white); - painter->drawLine(QPointF(-kNodeTerminalX, 0), QPointF(-47, 0)); - painter->drawLine(QPointF(47, 0), QPointF(kNodeTerminalX, 0)); - painter->drawRect(box); - painter->drawText(box, Qt::AlignCenter, QStringLiteral("MOVE")); - painter->drawText( - QRectF(-kCellWidth / 2.0, -40, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? QStringLiteral("%1 -> %2") - .arg(wordOperandText(move->source)) - .arg(registerAddressText(move->destination)) - : tr("< 源 -> 目标 >")); - } - else if (const auto *arithmetic = - std::get_if(&config_)) - { - const QRectF box(-47, -18, 94, 36); - painter->fillRect(box.adjusted(-1, -1, 1, 1), Qt::white); - painter->drawLine(QPointF(-kNodeTerminalX, 0), QPointF(-47, 0)); - painter->drawLine(QPointF(47, 0), QPointF(kNodeTerminalX, 0)); - painter->drawRect(box); - painter->drawText( - box, - Qt::AlignCenter, - arithmetic->operation == ArithmeticOperation::Add - ? QStringLiteral("ADD") : QStringLiteral("SUB")); - painter->drawText( - QRectF(-kCellWidth / 2.0, -40, kCellWidth, 18), - Qt::AlignCenter, - configured_ ? QStringLiteral("%1,%2 -> %3") - .arg(wordOperandText(arithmetic->left)) - .arg(wordOperandText(arithmetic->right)) - .arg(registerAddressText(arithmetic->destination)) - : tr("< 操作数 -> 目标 >")); - } - } - - const std::string &nodeId() const { return node_id_; } - const std::string &rungId() const { return rung_id_; } - bool isConditionNode() const { return condition_; } - -private: - std::string node_id_; - std::string rung_id_; - LogicNodeConfig config_; - QString register_comment_; - bool configured_ = true; - bool active_ = false; - bool faulted_ = false; - bool condition_ = true; -}; - -class LogicEditorWidget::WireItem final : public QGraphicsItem -{ -public: - WireItem( - const std::string &expression_id, - const std::string &rung_id, - const QPointF ¢er, - int column_span, - bool active) - : expression_id_(expression_id), - rung_id_(rung_id), - width_(static_cast(column_span) * kCellWidth), - active_(active) - { - setPos(center); - setFlag(ItemIsSelectable, true); - setZValue(2.0); - setToolTip(LogicEditorWidget::tr("横线:%1 列,可用触点直接替换") - .arg(column_span)); - } - - QRectF boundingRect() const override - { - return {-width_ / 2.0, -kCellHeight / 2.0, width_, kCellHeight}; - } - - void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) override - { - const bool selected = (option->state & QStyle::State_Selected) != 0; - if (selected) - { - painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor); - painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); - painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); } - painter->setPen(ladderPen(active_)); - painter->drawLine(QPointF(-width_ / 2.0, 0), QPointF(width_ / 2.0, 0)); } - const std::string &expressionId() const { return expression_id_; } - const std::string &rungId() const { return rung_id_; } - private: - std::string expression_id_; - std::string rung_id_; - qreal width_ = kCellWidth; - bool active_ = false; + std::vector rows_; }; -class LogicEditorWidget::WireCellItem final : public QGraphicsItem +class CellContentItem final : public QGraphicsItem { public: - WireCellItem( - const std::string &expression_id, + CellContentItem( + const LadderCell &cell, const std::string &rung_id, - int column_offset, - const QPointF ¢er) - : expression_id_(expression_id), - rung_id_(rung_id), - column_offset_(column_offset) - { - setPos(center); - setFlag(ItemIsSelectable, true); - setZValue(2.2); - setToolTip(LogicEditorWidget::tr("横线网格:第 %1 格,可用触点原位替换") - .arg(column_offset + 1)); + int column, + const QPointF &top_left, + bool input_active, + bool output_active, + bool faulted) + : cell_(cell), input_active_(input_active), + output_active_(output_active), faulted_(faulted) + { + setPos(top_left); + setZValue(10.0); + setData(0, QStringLiteral("cell")); + setData(1, QString::fromStdString(rung_id)); + setData(2, column); + setData(3, QString::fromStdString( + cell.node.has_value() ? cell.node->id : cell.id)); + setData(4, static_cast(cell.kind)); + setData(5, QString::fromStdString(cell.id)); + setData(6, input_active_); + setData(7, output_active_); } QRectF boundingRect() const override { - return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; + return {0.0, 0.0, kCellWidth, kRowHeight}; } - void paint( - QPainter *painter, - const QStyleOptionGraphicsItem *option, - QWidget *) override + void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { - if ((option->state & QStyle::State_Selected) == 0) + if (cell_.kind == LadderCellKind::Gap) { return; } - painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor); - painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); - painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); - painter->setPen(ladderPen(false)); - painter->drawLine( - QPointF(-kCellWidth / 2.0, 0), QPointF(kCellWidth / 2.0, 0)); - } - - const std::string &expressionId() const { return expression_id_; } - const std::string &rungId() const { return rung_id_; } - int columnOffset() const { return column_offset_; } - -private: - std::string expression_id_; - std::string rung_id_; - int column_offset_ = 0; -}; - -class LogicEditorWidget::GapCellItem final : public QGraphicsItem -{ -public: - GapCellItem( - const std::string &expression_id, - const std::string &rung_id, - int column_offset, - const QPointF ¢er) - : expression_id_(expression_id), - rung_id_(rung_id), - column_offset_(column_offset) - { - setPos(center); - setFlag(ItemIsSelectable, true); - setZValue(2.2); - setToolTip(LogicEditorWidget::tr("断路网格:第 %1 格,可补横线或插入触点") - .arg(column_offset + 1)); - } - - QRectF boundingRect() const override - { - return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; - } - - void paint( - QPainter *painter, - const QStyleOptionGraphicsItem *option, - QWidget *) override - { - if ((option->state & QStyle::State_Selected) == 0) + painter->setRenderHint(QPainter::Antialiasing, true); + const auto use_pen = [this, painter](bool active) + { + painter->setPen(ladderPen(active, faulted_)); + }; + const qreal center_x = kCellWidth / 2.0; + const qreal center_y = kRowHeight / 2.0 + 6.0; + if (cell_.kind == LadderCellKind::Wire || !cell_.node.has_value()) { + use_pen(input_active_); + painter->drawLine( + QPointF(0.0, center_y), QPointF(center_x, center_y)); + use_pen(output_active_); + painter->drawLine( + QPointF(center_x, center_y), QPointF(kCellWidth, center_y)); return; } - painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor); - painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); - painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); - } - const std::string &expressionId() const { return expression_id_; } - const std::string &rungId() const { return rung_id_; } - int columnOffset() const { return column_offset_; } + const LogicNode &node = *cell_.node; + QFont font = painter->font(); + font.setPointSizeF(8.5); + painter->setFont(font); + std::visit( + [&](const auto &config) + { + using Config = std::decay_t; + if constexpr (std::is_same_v + || std::is_same_v) + { + const qreal left = center_x - 15.0; + const qreal right = center_x + 15.0; + use_pen(input_active_); + painter->drawLine(QPointF(0.0, center_y), QPointF(left, center_y)); + use_pen(output_active_); + painter->drawLine(QPointF(right, center_y), QPointF(kCellWidth, center_y)); + painter->drawLine(QPointF(left, center_y - 14.0), QPointF(left, center_y + 14.0)); + painter->drawLine(QPointF(right, center_y - 14.0), QPointF(right, center_y + 14.0)); + if constexpr (std::is_same_v) + { + if (config.mode == ContactMode::NormallyClosed) + { + painter->drawLine( + QPointF(left - 4.0, center_y + 17.0), + QPointF(right + 4.0, center_y - 17.0)); + } + } + else + { + painter->drawText( + QRectF(center_x - 13.0, center_y - 12.0, 26.0, 24.0), + Qt::AlignCenter, + config.mode == EdgeMode::Rising + ? QStringLiteral("P") : QStringLiteral("N")); + } + painter->drawText( + QRectF(2.0, 3.0, kCellWidth - 4.0, 20.0), + Qt::AlignCenter, + node.configured + ? registerAddressText(config.address) + : QStringLiteral("")); + } + else if constexpr (std::is_same_v) + { + const QRectF box(7.0, center_y - 15.0, kCellWidth - 14.0, 30.0); + use_pen(input_active_); + painter->drawLine(QPointF(0.0, center_y), QPointF(box.left(), center_y)); + use_pen(output_active_); + painter->drawLine(QPointF(box.right(), center_y), QPointF(kCellWidth, center_y)); + painter->drawRect(box); + QFont compare_font = painter->font(); + compare_font.setPointSizeF(7.5); + painter->setFont(compare_font); + painter->drawText( + box.adjusted(2.0, 0.0, -2.0, 0.0), + Qt::AlignCenter, + node.configured + ? QStringLiteral("%1 %2 %3") + .arg(registerAddressText(config.address)) + .arg(comparisonText(config.comparison)) + .arg(config.value) + : QStringLiteral("<比较>")); + } + else + { + use_pen(input_active_); + painter->drawLine( + QPointF(0.0, center_y), QPointF(center_x, center_y)); + use_pen(output_active_); + painter->drawLine( + QPointF(center_x, center_y), QPointF(kCellWidth, center_y)); + } + }, + node.config); + } private: - std::string expression_id_; - std::string rung_id_; - int column_offset_ = 0; + LadderCell cell_; + bool input_active_ = false; + bool output_active_ = false; + bool faulted_ = false; }; -class LogicEditorWidget::EmptySlotItem final : public QGraphicsItem +class OutputContentItem final : public QGraphicsItem { public: - EmptySlotItem( - const std::string &rung_id, - int column, - const QPointF ¢er, - bool show_label, - std::string branch_expression_id = {}, - bool branch_active = false) - : rung_id_(rung_id), - branch_expression_id_(std::move(branch_expression_id)), - column_(column), - show_label_(show_label), - branch_active_(branch_active) - { - setPos(center); - setFlag(ItemIsSelectable, true); - setZValue(2.0); - setToolTip( - branch_expression_id_.empty() - ? LogicEditorWidget::tr( - "空条件网格:第 %1 列,点击后可插入触点") - .arg(column + 1) - : LogicEditorWidget::tr( - "并联空网格:第 %1 格,点击后可插入触点") - .arg(column + 1)); + OutputContentItem( + const LadderRung &rung, + const QPointF &top_left, + bool input_active, + bool symbol_active, + bool faulted) + : rung_id_(rung.id), output_(rung.output), + input_active_(input_active), symbol_active_(symbol_active), + faulted_(faulted) + { + setPos(top_left); + setZValue(10.0); + setData(0, QStringLiteral("output")); + setData(1, QString::fromStdString(rung.id)); + setData(2, QString::fromStdString( + rung.output.has_value() ? rung.output->id : std::string{})); + setData(3, input_active_); + setData(4, symbol_active_); } QRectF boundingRect() const override { - return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; + return {0.0, 0.0, kOutputWidth, kRowHeight}; } - void paint( - QPainter *painter, - const QStyleOptionGraphicsItem *option, - QWidget *) override + void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { - const bool selected = (option->state & QStyle::State_Selected) != 0; - if (selected) - { - painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor); - painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); - painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); - } - if (!branch_expression_id_.empty()) + if (!output_.has_value()) { - // 选中补线格时仍需把结构横线绘制在选框上层 - painter->setPen(ladderPen(branch_active_)); - painter->drawLine( - QPointF(-kCellWidth / 2.0, 0), - QPointF(kCellWidth / 2.0, 0)); + return; } - if (show_label_) + painter->setRenderHint(QPainter::Antialiasing, true); + const auto use_pen = [this, painter](bool active) { - painter->setPen(kPlaceholderColor); - painter->drawText( - QRectF(-kCellWidth / 2.0, -20, kCellWidth, 40), - Qt::AlignCenter, - LogicEditorWidget::tr("添加条件")); - } - } - - const std::string &rungId() const { return rung_id_; } - const std::string &branchExpressionId() const - { - return branch_expression_id_; - } - int column() const { return column_; } - -private: - std::string rung_id_; - std::string branch_expression_id_; - int column_ = 0; - bool show_label_ = false; - bool branch_active_ = false; -}; - -class LogicEditorWidget::OutputSlotItem final : public QGraphicsItem -{ -public: - OutputSlotItem(const std::string &rung_id, const QPointF ¢er) - : rung_id_(rung_id) - { - setPos(center); - setFlag(ItemIsSelectable, true); - setZValue(1.0); - setToolTip(LogicEditorWidget::tr("输出槽:双击输入 OUT、SET、RST、MOV、ADD 或 SUB")); - } - - QRectF boundingRect() const override - { - return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight}; - } - - void paint( - QPainter *painter, - const QStyleOptionGraphicsItem *option, - QWidget *) override - { - const bool selected = (option->state & QStyle::State_Selected) != 0; - const QRectF bounds = boundingRect().adjusted(12, 18, -12, -18); - painter->setPen(QPen( - selected ? kSelectionBorderColor : kPlaceholderColor, - selected ? 1.8 : 1.2, - Qt::DashLine)); - painter->setBrush(QColor(255, 255, 255, 220)); - painter->drawRect(bounds); - painter->setPen(kPlaceholderColor); - painter->drawText(bounds, Qt::AlignCenter, LogicEditorWidget::tr("输出指令")); + painter->setPen(ladderPen(active, faulted_)); + }; + const qreal center_y = kRowHeight / 2.0 + 6.0; + const LogicNode &node = *output_; + QFont font = painter->font(); + font.setPointSizeF(8.5); + painter->setFont(font); + std::visit( + [&](const auto &config) + { + using Config = std::decay_t; + if constexpr (std::is_same_v) + { + const qreal center_x = kOutputWidth / 2.0; + use_pen(input_active_); + painter->drawLine(QPointF(0.0, center_y), QPointF(center_x - 22.0, center_y)); + painter->drawLine(QPointF(center_x + 22.0, center_y), QPointF(kOutputWidth, center_y)); + use_pen(symbol_active_); + QPainterPath left; + left.moveTo(center_x - 3.0, center_y - 18.0); + left.cubicTo( + center_x - 25.0, center_y - 14.0, + center_x - 25.0, center_y + 14.0, + center_x - 3.0, center_y + 18.0); + painter->drawPath(left); + QPainterPath right; + right.moveTo(center_x + 3.0, center_y - 18.0); + right.cubicTo( + center_x + 25.0, center_y - 14.0, + center_x + 25.0, center_y + 14.0, + center_x + 3.0, center_y + 18.0); + painter->drawPath(right); + if (config.mode != CoilMode::Normal) + { + painter->drawText( + QRectF(center_x - 13.0, center_y - 13.0, 26.0, 26.0), + Qt::AlignCenter, + config.mode == CoilMode::Set + ? QStringLiteral("S") : QStringLiteral("R")); + } + painter->drawText( + QRectF(2.0, 3.0, kOutputWidth - 4.0, 20.0), + Qt::AlignCenter, + node.configured + ? registerAddressText(config.address) + : QStringLiteral("")); + } + else if constexpr (std::is_same_v) + { + const QRectF box(26.0, 11.0, kOutputWidth - 52.0, kRowHeight - 22.0); + use_pen(input_active_); + painter->drawLine(QPointF(0.0, center_y), QPointF(box.left(), center_y)); + painter->drawLine(QPointF(box.right(), center_y), QPointF(kOutputWidth, center_y)); + use_pen(symbol_active_); + painter->drawRect(box); + QFont mnemonic_font = painter->font(); + mnemonic_font.setBold(true); + painter->setFont(mnemonic_font); + painter->drawText( + QRectF(box.left(), box.top() + 2.0, box.width(), 20.0), + Qt::AlignCenter, + QStringLiteral("MOV")); + mnemonic_font.setBold(false); + mnemonic_font.setPointSizeF(8.0); + painter->setFont(mnemonic_font); + painter->drawText( + QRectF(box.left() + 4.0, box.top() + 23.0, box.width() - 8.0, 20.0), + Qt::AlignCenter, + node.configured + ? QStringLiteral("%1 -> %2") + .arg(wordOperandText(config.source)) + .arg(registerAddressText(config.destination)) + : QStringLiteral("<源> -> <目标>")); + } + else if constexpr (std::is_same_v) + { + const QRectF box(18.0, 11.0, kOutputWidth - 36.0, kRowHeight - 22.0); + use_pen(input_active_); + painter->drawLine(QPointF(0.0, center_y), QPointF(box.left(), center_y)); + painter->drawLine(QPointF(box.right(), center_y), QPointF(kOutputWidth, center_y)); + use_pen(symbol_active_); + painter->drawRect(box); + QFont mnemonic_font = painter->font(); + mnemonic_font.setBold(true); + painter->setFont(mnemonic_font); + painter->drawText( + QRectF(box.left(), box.top() + 2.0, box.width(), 20.0), + Qt::AlignCenter, + config.operation == ArithmeticOperation::Add + ? QStringLiteral("ADD") : QStringLiteral("SUB")); + mnemonic_font.setBold(false); + mnemonic_font.setPointSizeF(8.0); + painter->setFont(mnemonic_font); + painter->drawText( + QRectF(box.left() + 4.0, box.top() + 23.0, box.width() - 8.0, 20.0), + Qt::AlignCenter, + node.configured + ? QStringLiteral("%1, %2 -> %3") + .arg(wordOperandText(config.left)) + .arg(wordOperandText(config.right)) + .arg(registerAddressText(config.destination)) + : QStringLiteral("<左>, <右> -> <目标>")); + } + else + { + use_pen(input_active_); + painter->drawLine( + QPointF(0.0, center_y), QPointF(kOutputWidth, center_y)); + } + }, + node.config); } - const std::string &rungId() const { return rung_id_; } - private: std::string rung_id_; + std::optional output_; + bool input_active_ = false; + bool symbol_active_ = false; + bool faulted_ = false; }; -class LogicEditorWidget::VerticalConnectorItem final : public QGraphicsItem +class VerticalConnectionItem final : public QGraphicsItem { public: - VerticalConnectorItem( - const std::string &branch_expression_id, - const std::string &rung_id, + VerticalConnectionItem( + const VerticalConnection &connection, qreal x, qreal top, qreal bottom, bool active) - : branch_expression_id_(branch_expression_id), - rung_id_(rung_id), - height_(bottom - top), - active_(active) + : height_(bottom - top), active_(active) { setPos(x, top); - setFlag(ItemIsSelectable, true); - setZValue(2.5); - setToolTip(LogicEditorWidget::tr("竖线连接:删除将移除对应并联支路")); + setZValue(20.0); + setData(0, QStringLiteral("vertical")); + setData(1, QString::fromStdString(connection.id)); + setData(2, QString::fromStdString(connection.upperRungId)); + setData(3, connection.columnBoundary); + setData(4, QString::fromStdString(connection.lowerRungId)); + setData(5, active_); } QRectF boundingRect() const override { - return {-8.0, 0.0, 16.0, height_}; + return {-7.0, 0.0, 14.0, height_}; } - void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) override + QPainterPath shape() const override { - const bool selected = (option->state & QStyle::State_Selected) != 0; - painter->setPen(QPen( - selected ? kSelectionBorderColor - : active_ ? kActiveColor : kLadderColor, - selected || active_ ? 3.0 : kLadderLineWidth)); - painter->drawLine(QPointF(0, 0), QPointF(0, height_)); - if (selected) - { - painter->setPen(QPen(kSelectionBorderColor, 1.0, Qt::DashLine)); - painter->drawRect(boundingRect().adjusted(1, 1, -1, -1)); - } + QPainterPath path; + path.moveTo(0.0, 0.0); + path.lineTo(0.0, height_); + QPainterPathStroker stroker; + stroker.setWidth(12.0); + return stroker.createStroke(path); } - const std::string &branchExpressionId() const + void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { - return branch_expression_id_; + painter->setPen(ladderPen(active_)); + painter->drawLine(QPointF(0.0, 0.0), QPointF(0.0, height_)); } - const std::string &rungId() const { return rung_id_; } private: - std::string branch_expression_id_; - std::string rung_id_; qreal height_ = 0.0; bool active_ = false; }; -class LogicEditorWidget::RungItem final : public QGraphicsItem +class SelectionOverlayItem final : public QGraphicsItem { public: - RungItem( - const LadderRung &rung, - int number, - qreal top, - qreal height, - qreal width, - qreal cursor_left) - : rung_id_(rung.id), - name_(QString::fromStdString(rung.name)), - comment_(QString::fromStdString(rung.comment)), - number_(number), - height_(height), - width_(width), - cursor_left_(cursor_left), - show_cursor_(!rung.condition.has_value()) + explicit SelectionOverlayItem(std::vector rectangles) + : rectangles_(std::move(rectangles)) { - setPos(0, top); - setFlag(ItemIsSelectable, true); - setZValue(-2.0); - if (!comment_.isEmpty()) + for (const QRectF &rectangle : rectangles_) { - setToolTip(comment_); + bounds_ = bounds_.isNull() ? rectangle : bounds_.united(rectangle); } + bounds_.adjust(-2.0, -2.0, 2.0, 2.0); + setZValue(100.0); } - QRectF boundingRect() const override { return {kSceneMargin, 0, width_, height_}; } - - QPainterPath shape() const override + QRectF boundingRect() const override { - QPainterPath path; - path.addRect(QRectF(kSceneMargin, 0, width_, kRungHeaderHeight)); - return path; + return bounds_; } - void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) override + void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { - const bool selected = (option->state & QStyle::State_Selected) != 0; - if (selected) - { - painter->fillRect(boundingRect(), QColor(240, 247, 250, 90)); - if (show_cursor_) - { - painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); - painter->drawRect(QRectF( - cursor_left_ + 3, - kRungHeaderHeight + 3, - kCellWidth - 6, - kCellHeight - 6)); - } - } - painter->setPen(QColor(QStringLiteral("#62717b"))); - QString title = tr("网络 %1").arg(number_); - if (!name_.isEmpty() && !name_.startsWith(tr("网络 "))) - { - title += QStringLiteral(":") + name_; - } - painter->drawText( - QRectF(kSceneMargin + 8, 5, 320, 22), - Qt::AlignLeft | Qt::AlignVCenter, - title); - if (!comment_.isEmpty()) + painter->setRenderHint(QPainter::Antialiasing, false); + QPen pen(kSelectionBorder, 1.4, Qt::DashLine); + pen.setCosmetic(true); + painter->setPen(pen); + painter->setBrush(QColor( + kSelectionFill.red(), + kSelectionFill.green(), + kSelectionFill.blue(), + 90)); + for (const QRectF &rectangle : rectangles_) { - painter->setPen(QColor(QStringLiteral("#7b8790"))); - const QString visible_comment = painter->fontMetrics().elidedText( - comment_, Qt::ElideRight, static_cast(width_ - 16.0)); - painter->drawText( - QRectF(kSceneMargin + 8, 24, width_ - 16, 22), - Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, - visible_comment); + painter->drawRect(rectangle.adjusted(2.5, 2.5, -2.5, -2.5)); } - painter->setPen(QPen(QColor(QStringLiteral("#d4dce1")), 1)); - painter->drawLine( - QPointF(kSceneMargin + 8, height_ - 1), - QPointF(kSceneMargin + width_ - 8, height_ - 1)); } - const std::string &rungId() const { return rung_id_; } - private: - std::string rung_id_; - QString name_; - QString comment_; - int number_ = 0; - qreal height_ = 0.0; - qreal width_ = 0.0; - qreal cursor_left_ = 0.0; - bool show_cursor_ = false; + std::vector rectangles_; + QRectF bounds_; }; -namespace { - -RenderResult renderExpression( - QGraphicsScene &scene, - const LogicEditorService &editor_service, - const ConditionExpression &expression, - const std::string &rung_id, - const QPointF &top_left, - const ExpressionMetrics &metrics, - const LogicTraceSnapshot &trace, - bool trace_enabled, - const std::string &fault_node_id) -{ - if (expression.kind == ConditionExpressionKind::Wire) - { - const bool active = trace_enabled && traceValue( - trace, &LogicTraceSnapshot::expressionPowerValues, expression.id); - const qreal width = static_cast(expression.wire->columnSpan) * kCellWidth; - const QPointF center( - top_left.x() + width / 2.0, - top_left.y() + kCellHeight / 2.0); - scene.addItem(new LogicEditorWidget::WireItem( - expression.id, - rung_id, - center, - expression.wire->columnSpan, - active)); - for (int column = 0; column < expression.wire->columnSpan; ++column) - { - scene.addItem(new LogicEditorWidget::WireCellItem( - expression.id, - rung_id, - column, - QPointF( - top_left.x() + (static_cast(column) + 0.5) - * kCellWidth, - center.y()))); - } - return { - QPointF(top_left.x(), center.y()), - QPointF(top_left.x() + width, center.y())}; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - const qreal width = static_cast(expression.gap->columnSpan) - * kCellWidth; - const QPointF center( - top_left.x() + width / 2.0, - top_left.y() + kCellHeight / 2.0); - for (int column = 0; column < expression.gap->columnSpan; ++column) - { - scene.addItem(new LogicEditorWidget::GapCellItem( - expression.id, - rung_id, - column, - QPointF( - top_left.x() + (static_cast(column) + 0.5) - * kCellWidth, - center.y()))); - } - return { - QPointF(top_left.x(), center.y()), - QPointF(top_left.x() + width, center.y())}; - } - if (expression.kind == ConditionExpressionKind::Node) - { - const QPointF center( - top_left.x() + kCellWidth / 2.0, - top_left.y() + kCellHeight / 2.0); - const bool node_active = trace_enabled && traceValue( - trace, &LogicTraceSnapshot::nodePowerValues, expression.node->id); - scene.addLine( - QLineF( - QPointF(top_left.x(), center.y()), - QPointF(top_left.x() + kCellWidth, center.y())), - ladderPen(node_active)); - scene.addItem(new LogicEditorWidget::NodeItem( - *expression.node, - rung_id, - center, - [&editor_service, &expression] - { - const std::optional address = - registerAddressForLogicNode(expression.node->config); - return address.has_value() - ? QString::fromStdString( - editor_service.registerCommentFor(*address)) - : QString{}; - }(), - node_active, - expression.node->id == fault_node_id, - true)); - return { - QPointF(top_left.x(), center.y()), - QPointF(top_left.x() + kCellWidth, center.y())}; - } - - if (expression.kind == ConditionExpressionKind::Series) - { - qreal x = top_left.x(); - RenderResult first; - RenderResult previous; - for (std::size_t index = 0; index < expression.children.size(); ++index) - { - const ExpressionMetrics child_metrics = measureExpression(expression.children[index]); - const RenderResult current = renderExpression( - scene, - editor_service, - expression.children[index], - rung_id, - QPointF(x, top_left.y()), - child_metrics, - trace, - trace_enabled, - fault_node_id); - if (index == 0U) - { - first = current; - } - else - { - const bool previous_active = trace_enabled && traceValue( - trace, - &LogicTraceSnapshot::expressionPowerValues, - expression.children[index - 1U].id); - addLineIfVisible( - scene, - QLineF(previous.output, current.input), - ladderPen(previous_active)); - } - previous = current; - x += static_cast(child_metrics.columns) * kCellWidth; - } - return {first.input, previous.output}; - } - - qreal y = top_left.y(); - std::vector branches; - branches.reserve(expression.children.size()); - for (const ConditionExpression &child : expression.children) - { - const ExpressionMetrics child_metrics = measureExpression(child); - branches.push_back(renderExpression( - scene, - editor_service, - child, - rung_id, - QPointF(top_left.x(), y), - child_metrics, - trace, - trace_enabled, - fault_node_id)); - y += static_cast(child_metrics.rows) * kCellHeight; - } - - const qreal left_join = top_left.x(); - const qreal right_join = top_left.x() + static_cast(metrics.columns) * kCellWidth; - const qreal top_y = branches.front().input.y(); - const bool parallel_input_active = trace_enabled && traceValue( - trace, &LogicTraceSnapshot::expressionInputValues, expression.id); - for (std::size_t index = 1; index < branches.size(); ++index) - { - const qreal segment_top = branches[index - 1U].input.y(); - const qreal segment_bottom = branches[index].input.y(); - const bool branch_active = trace_enabled && traceValue( - trace, - &LogicTraceSnapshot::expressionPowerValues, - expression.children[index].id); - scene.addItem(new LogicEditorWidget::VerticalConnectorItem( - expression.children[index].id, - rung_id, - left_join, - segment_top, - segment_bottom, - parallel_input_active)); - scene.addItem(new LogicEditorWidget::VerticalConnectorItem( - expression.children[index].id, - rung_id, - right_join, - segment_top, - segment_bottom, - branch_active)); - } - for (std::size_t index = 0; index < branches.size(); ++index) - { - const bool branch_active = trace_enabled && traceValue( - trace, - &LogicTraceSnapshot::expressionPowerValues, - expression.children[index].id); - addLineIfVisible( - scene, - QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), - ladderPen(branch_active)); - } - return {QPointF(left_join, top_y), QPointF(right_join, top_y)}; -} - } // namespace -LogicEditorWidget::LogicEditorWidget(LogicEditorService &editor_service, QWidget *parent) +LogicEditorWidget::LogicEditorWidget( + LogicEditorService &editor_service, + QWidget *parent) : QGraphicsView(parent), editor_service_(editor_service), - command_service_(editor_service) + command_service_(editor_service), + scene_(new QGraphicsScene(this)) { - scene_ = new QGraphicsScene(this); setScene(scene_); setRenderHint(QPainter::Antialiasing, true); - setBackgroundBrush(Qt::white); + setBackgroundBrush(kCanvasBackground); setDragMode(QGraphicsView::NoDrag); setAlignment(Qt::AlignLeft | Qt::AlignTop); selection_band_ = new QRubberBand(QRubberBand::Rectangle, viewport()); selection_band_->hide(); - connect(scene_, &QGraphicsScene::selectionChanged, - this, &LogicEditorWidget::handleSelectionChanged); + command_editor_ = new QLineEdit(viewport()); command_editor_->setObjectName(QStringLiteral("logicCommandInput")); command_editor_->setPlaceholderText( - tr("输入 PLC 指令,例如 LD M0;触点用 M,数据用 D")); + tr("输入 PLC 指令,例如 LD M0、AND M1、OUT M10")); command_editor_->setClearButtonEnabled(true); command_editor_->setVisible(false); command_editor_->setToolTip( - tr("触点使用 M 地址,比较使用 D 地址+常量,MOV/ADD/SUB 使用 D 地址")); + tr("触点使用 M 地址,比较和数据指令使用 D 地址")); command_completer_ = new QCompleter(command_editor_); auto *command_model = new QStandardItemModel(0, 3, command_completer_); command_model->setHeaderData(0, Qt::Horizontal, tr("指令")); command_model->setHeaderData(1, Qt::Horizontal, tr("操作数")); command_model->setHeaderData(2, Qt::Horizontal, tr("说明")); - for (const LogicCommandSuggestion &suggestion : LogicCommandService::suggestions()) + for (const LogicCommandSuggestion &suggestion + : LogicCommandService::suggestions()) { - auto *mnemonic = new QStandardItem( - QString::fromUtf8(suggestion.mnemonic.c_str())); - auto *operand_hint = new QStandardItem( - QString::fromUtf8(suggestion.operand_hint.c_str())); - auto *description = new QStandardItem( - QString::fromUtf8(suggestion.description.c_str())); - if (!suggestion.supported) - { - mnemonic->setEnabled(false); - operand_hint->setEnabled(false); - description->setEnabled(false); - } - for (QStandardItem *item : {mnemonic, operand_hint, description}) + QList row{ + new QStandardItem(QString::fromStdString(suggestion.mnemonic)), + new QStandardItem(QString::fromStdString(suggestion.operand_hint)), + new QStandardItem(QString::fromStdString(suggestion.description))}; + for (QStandardItem *item : row) { + item->setEnabled(suggestion.supported); item->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); } - QList row{mnemonic, operand_hint, description}; command_model->appendRow(row); } command_completer_->setModel(command_model); @@ -1195,41 +666,128 @@ LogicEditorWidget::LogicEditorWidget(LogicEditorService &editor_service, QWidget command_popup->setHeaderHidden(false); command_popup->setUniformRowHeights(true); command_popup->setAlternatingRowColors(true); - command_popup->setTextElideMode(Qt::ElideRight); command_popup->setMinimumSize(620, 300); - command_popup->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); command_completer_->setPopup(command_popup); - command_popup->header()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); command_popup->header()->setStretchLastSection(true); command_popup->setColumnWidth(0, 96); command_popup->setColumnWidth(1, 190); command_editor_->setCompleter(command_completer_); - connect(command_editor_, &QLineEdit::textEdited, - this, [this](const QString &text) - { - if (command_completer_ == nullptr || command_editor_ == nullptr) - { - return; - } - command_completer_->setCompletionPrefix( - commandCompletionPrefix(text)); - command_completer_->complete(command_editor_->rect()); - }); - connect(command_editor_, &QLineEdit::returnPressed, - this, &LogicEditorWidget::commitCommandInput); + connect( + command_editor_, &QLineEdit::textEdited, + this, + [this](const QString &text) + { + command_completer_->setCompletionPrefix( + commandCompletionPrefix(text)); + command_completer_->complete(command_editor_->rect()); + }); + connect( + command_editor_, &QLineEdit::returnPressed, + this, &LogicEditorWidget::commitCommandInput); command_editor_->installEventFilter(this); } void LogicEditorWidget::setLogicId(const std::string &logic_id) { - if (logic_id_ == logic_id) + const bool logic_changed = logic_id_ != logic_id; + logic_id_ = logic_id; + if (logic_changed) { - return; + cancelCommandInput(); + selected_rung_id_.clear(); + selected_node_ids_.clear(); + selected_cells_.clear(); + selected_output_rung_ids_.clear(); + selected_vertical_connection_ids_.clear(); + selected_row_ids_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + selected_vertical_connection_id_.clear(); + } + else if (!selected_rung_id_.empty() + && editor_service_.findRung(logic_id_, selected_rung_id_) == nullptr) + { + selected_rung_id_.clear(); + selected_node_ids_.clear(); + selected_cells_.clear(); + selected_output_rung_ids_.clear(); + selected_vertical_connection_ids_.clear(); + selected_row_ids_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + selected_vertical_connection_id_.clear(); } - logic_id_ = logic_id; - cancelCommandInput(); - current_rung_id_.clear(); - reloadLogic(); + else + { + if (!selected_vertical_connection_id_.empty() + && editor_service_.findConnection( + logic_id_, selected_vertical_connection_id_) == nullptr) + { + selected_vertical_connection_id_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + } + selected_cells_.erase( + std::remove_if( + selected_cells_.begin(), + selected_cells_.end(), + [this](const auto &position) + { + const LadderCell *cell = editor_service_.findCell( + logic_id_, position.first, position.second); + return cell == nullptr || cell->kind == LadderCellKind::Gap; + }), + selected_cells_.end()); + selected_output_rung_ids_.erase( + std::remove_if( + selected_output_rung_ids_.begin(), + selected_output_rung_ids_.end(), + [this](const std::string &rung_id) + { + const LadderRung *rung = editor_service_.findRung( + logic_id_, rung_id); + return rung == nullptr || !rung->output.has_value(); + }), + selected_output_rung_ids_.end()); + selected_vertical_connection_ids_.erase( + std::remove_if( + selected_vertical_connection_ids_.begin(), + selected_vertical_connection_ids_.end(), + [this](const std::string &connection_id) + { + return editor_service_.findConnection( + logic_id_, connection_id) == nullptr; + }), + selected_vertical_connection_ids_.end()); + selected_row_ids_.erase( + std::remove_if( + selected_row_ids_.begin(), + selected_row_ids_.end(), + [this](const std::string &rung_id) + { + return editor_service_.findRung(logic_id_, rung_id) == nullptr; + }), + selected_row_ids_.end()); + synchronizeSelectedNodes(); + selected_vertical_connection_id_ = + selected_vertical_connection_ids_.empty() + ? std::string{} : selected_vertical_connection_ids_.back(); + if (selected_cell_ + && editor_service_.findCell( + logic_id_, selected_rung_id_, selected_column_) + == nullptr) + { + selected_column_ = -1; + selected_cell_ = false; + } + } + rebuildScene(); } void LogicEditorWidget::setEditingEnabled(bool enabled) @@ -1238,18 +796,37 @@ void LogicEditorWidget::setEditingEnabled(bool enabled) if (!enabled) { cancelCommandInput(); + mouse_wire_mode_ = MouseWireMode::Select; + selection_pressed_ = false; + selection_dragging_ = false; + selection_band_->hide(); } - setInteractive(enabled); + rebuildScene(); +} + +void LogicEditorWidget::setMouseWireMode(MouseWireMode mode) +{ + mouse_wire_mode_ = editing_enabled_ ? mode : MouseWireMode::Select; + selection_pressed_ = false; + selection_dragging_ = false; + selection_band_->hide(); + viewport()->setCursor(mouse_wire_mode_ == MouseWireMode::Select + ? Qt::ArrowCursor : Qt::CrossCursor); +} + +LogicEditorWidget::MouseWireMode LogicEditorWidget::mouseWireMode() const +{ + return mouse_wire_mode_; } void LogicEditorWidget::setRuntimeTrace( - const LogicTraceSnapshot &trace, const std::string &fault_node_id) + const LogicTraceSnapshot &trace, + const std::string &fault_node_id) { - // 轨迹只是模型的只读投影;编辑器不因显示轨迹而修改工程表达式 - trace_ = trace; + trace_ = trace.forLogic(logic_id_); fault_node_id_ = fault_node_id; runtime_trace_enabled_ = true; - reloadLogic(); + rebuildScene(); } void LogicEditorWidget::clearRuntimeTrace() @@ -1257,7 +834,7 @@ void LogicEditorWidget::clearRuntimeTrace() trace_.clear(); fault_node_id_.clear(); runtime_trace_enabled_ = false; - reloadLogic(); + rebuildScene(); } bool LogicEditorWidget::runtimeTraceEnabled() const @@ -1267,658 +844,1007 @@ bool LogicEditorWidget::runtimeTraceEnabled() const void LogicEditorWidget::reloadLogic() { - // 逻辑或选择变化后重新计算网格布局和可点击插入目标 - scene_->clearSelection(); - scene_->clear(); - const ControlLogic *logic = editor_service_.findLogic(logic_id_); - if (logic == nullptr) - { - scene_->setSceneRect(0, 0, kMinimumSceneWidth, 400); - return; - } - const bool current_rung_exists = std::any_of( - logic->rungs.cbegin(), logic->rungs.cend(), - [this](const LadderRung &rung) - { - return rung.id == current_rung_id_; - }); - if (!current_rung_id_.empty() && !current_rung_exists) - { - // 撤销或重做可能移除当前网络,不能把过期 ID 继续传给编辑服务 - current_rung_id_.clear(); - } - if (current_rung_id_.empty() && !logic->rungs.empty()) - { - current_rung_id_ = logic->rungs.front().id; - } - - const int condition_columns = ProjectLimits::kMaximumConditionColumns; - const int grid_columns = std::max(kMinimumLogicColumns, condition_columns + 1); - const qreal left_rail_x = kSceneMargin + kRailInset; - const qreal right_rail_x = left_rail_x + static_cast(grid_columns) * kCellWidth; - const qreal scene_width = std::max(kMinimumSceneWidth, right_rail_x + kSceneMargin); - - qreal top = kSceneMargin; - int number = 1; - for (const LadderRung &rung : logic->rungs) - { - const ExpressionMetrics metrics = rung.condition.has_value() - ? measureExpression(*rung.condition) : ExpressionMetrics{}; - const int occupied_columns = rung.condition.has_value() ? metrics.columns : 0; - const int grid_rows = std::max(1, metrics.rows); - const qreal grid_top = top + kRungHeaderHeight; - const qreal rung_height = kRungHeaderHeight - + static_cast(grid_rows) * kCellHeight + 12.0; - scene_->addItem(new RungItem( - rung, - number, - top, - rung_height, - scene_width - 2.0 * kSceneMargin, - left_rail_x)); - addGrid(*scene_, left_rail_x, grid_top, grid_columns, grid_rows); - scene_->addLine( - QLineF(left_rail_x, grid_top, left_rail_x, - grid_top + static_cast(grid_rows) * kCellHeight), - QPen(kLadderColor, 2.4)); - scene_->addLine( - QLineF(right_rail_x, grid_top, right_rail_x, - grid_top + static_cast(grid_rows) * kCellHeight), - QPen(kLadderColor, 2.4)); - - const qreal main_y = grid_top + kCellHeight / 2.0; - QPointF expression_output(left_rail_x, main_y); - if (rung.condition.has_value()) - { - const RenderResult rendered = renderExpression( - *scene_, - editor_service_, - *rung.condition, - rung.id, - QPointF(left_rail_x, grid_top), - metrics, - trace_, - runtime_trace_enabled_, - fault_node_id_); - expression_output = rendered.output; - } - for (int column = occupied_columns; - column < ProjectLimits::kMaximumConditionColumns; - ++column) - { - scene_->addItem(new EmptySlotItem( - rung.id, - column, - QPointF( - left_rail_x + (static_cast(column) + 0.5) - * kCellWidth, - main_y), - column == 0 && !rung.output.has_value())); - } + setLogicId(logic_id_); +} - const qreal output_left = right_rail_x - kCellWidth; - if (rung.output.has_value()) - { - const bool rung_active = runtime_trace_enabled_ && traceValue( - trace_, &LogicTraceSnapshot::rungValues, rung.id); - scene_->addLine( - QLineF(expression_output, QPointF(output_left, main_y)), - ladderPen(rung_active)); - scene_->addLine( - QLineF(QPointF(output_left, main_y), QPointF(right_rail_x, main_y)), - ladderPen(rung_active)); - scene_->addItem(new NodeItem( - *rung.output, - rung.id, - QPointF(output_left + kCellWidth / 2.0, main_y), - [&rung, this] - { - const std::optional address = - registerAddressForLogicNode(rung.output->config); - return address.has_value() - ? QString::fromStdString( - editor_service_.registerCommentFor(*address)) - : QString{}; - }(), - rung_active, - rung.output->id == fault_node_id_, - false)); - } - else - { - scene_->addItem(new OutputSlotItem( - rung.id, - QPointF(output_left + kCellWidth / 2.0, main_y))); - } - top += rung_height + kRungGap; - ++number; - } - scene_->setSceneRect(0, 0, scene_width, std::max(400.0, top + kSceneMargin)); +void LogicEditorWidget::clearSelection() +{ + selected_rung_id_.clear(); + selected_node_ids_.clear(); + selected_cells_.clear(); + selected_output_rung_ids_.clear(); + selected_vertical_connection_ids_.clear(); + selected_row_ids_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + selected_vertical_connection_id_.clear(); + rebuildScene(); + emit nodeSelected(QString{}); } void LogicEditorWidget::selectNode(const std::string &node_id) { - for (QGraphicsItem *item : scene_->items()) - { - if (NodeItem *node = dynamic_cast(item)) - { - node->setSelected(node->nodeId() == node_id); - if (node->nodeId() == node_id) + selected_node_ids_.clear(); + selected_cells_.clear(); + selected_output_rung_ids_.clear(); + selected_vertical_connection_ids_.clear(); + selected_row_ids_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + selected_vertical_connection_id_.clear(); + if (!node_id.empty()) + { + selected_node_ids_.push_back(node_id); + const std::string rung_id = editor_service_.rungIdForNode( + logic_id_, node_id); + const LadderRung *rung = editor_service_.findRung(logic_id_, rung_id); + if (rung != nullptr) + { + selected_rung_id_ = rung_id; + for (std::size_t index = 0U; index < rung->cells.size(); ++index) { - current_rung_id_ = node->rungId(); - ensureVisible(node); + if (rung->cells[index].node.has_value() + && rung->cells[index].node->id == node_id) + { + selected_column_ = static_cast(index); + selected_cell_ = true; + selected_cells_.push_back({ + rung_id, static_cast(index)}); + break; + } + } + if (!selected_cell_ && rung->output.has_value() + && rung->output->id == node_id) + { + selected_output_rung_ids_.push_back(rung_id); } - } - else - { - item->setSelected(false); } } + rebuildScene(); + emit nodeSelected(QString::fromStdString(node_id)); } -void LogicEditorWidget::selectExpression(const std::string &expression_id) +void LogicEditorWidget::focusSyntaxLocation( + const std::string &rung_id, int column) { - for (QGraphicsItem *item : scene_->items()) + const LadderRung *rung = editor_service_.findRung(logic_id_, rung_id); + if (rung == nullptr) { - bool selected = false; - if (NodeItem *node = dynamic_cast(item)) + return; + } + cancelCommandInput(); + selected_node_ids_.clear(); + selected_cells_.clear(); + selected_output_rung_ids_.clear(); + selected_vertical_connection_ids_.clear(); + selected_row_ids_.clear(); + selected_vertical_connection_id_.clear(); + selected_rung_id_ = rung_id; + selected_boundary_ = false; + + const bool output = column >= ProjectLimits::kMaximumLadderColumns; + selected_output_ = output; + selected_cell_ = !output; + selected_column_ = output + ? ProjectLimits::kMaximumConditionColumns + : std::max(0, std::min( + column - 1, ProjectLimits::kMaximumConditionColumns - 1)); + if (output) + { + selected_output_rung_ids_.push_back(rung_id); + if (rung->output.has_value()) + { + selected_node_ids_.push_back(rung->output->id); + } + } + else + { + const LadderCell &cell = rung->cells[ + static_cast(selected_column_)]; + if (cell.kind != LadderCellKind::Gap) { - selected = node->isConditionNode() && node->nodeId() == expression_id; - if (selected) - { - current_rung_id_ = node->rungId(); - ensureVisible(node); - } + selected_cells_.push_back({rung_id, selected_column_}); } - else if (WireItem *wire = dynamic_cast(item)) + if (cell.node.has_value()) { - selected = wire->expressionId() == expression_id; - if (selected) - { - current_rung_id_ = wire->rungId(); - ensureVisible(wire); - } + selected_node_ids_.push_back(cell.node->id); } - item->setSelected(selected); } -} + rebuildScene(); + notifySelectionChanged(); -void LogicEditorWidget::selectWire(const std::string &wire_id) -{ - selectExpression(wire_id); + const RowLayout *layout = layoutForRung(rung_id); + if (layout != nullptr) + { + const qreal x = output + ? kConditionRight + : kLeftBus + static_cast(selected_column_) * kCellWidth; + ensureVisible( + QRectF( + x, + layout->gridTop, + output ? kOutputWidth : kCellWidth, + kRowHeight), + 24, + 24); + } + setFocus(Qt::OtherFocusReason); } std::string LogicEditorWidget::selectedNodeId() const { - const std::vector ids = selectedNodeIds(); - return ids.size() == 1U ? ids.front() : std::string{}; + return selected_node_ids_.empty() ? std::string{} : selected_node_ids_.front(); } std::vector LogicEditorWidget::selectedNodeIds() const { - std::vector> positioned_ids; - for (QGraphicsItem *item : scene_->selectedItems()) - { - if (const NodeItem *node = dynamic_cast(item)) - { - positioned_ids.emplace_back(node->scenePos(), node->nodeId()); - } - } - std::sort( - positioned_ids.begin(), positioned_ids.end(), - [](const auto &left, const auto &right) - { - if (!qFuzzyCompare(left.first.y(), right.first.y())) - { - return left.first.y() < right.first.y(); - } - return left.first.x() < right.first.x(); - }); - std::vector ids; - ids.reserve(positioned_ids.size()); - for (const auto &positioned_id : positioned_ids) - { - ids.push_back(positioned_id.second); - } - return ids; + return selected_node_ids_; } -std::vector LogicEditorWidget::selectedExpressionIds() const +std::string LogicEditorWidget::selectedRungId() const { - std::vector> positioned_ids; - for (QGraphicsItem *item : scene_->selectedItems()) - { - if (const NodeItem *node = dynamic_cast(item)) - { - if (node->isConditionNode()) - { - positioned_ids.emplace_back(node->scenePos(), node->nodeId()); - } - } - else if (const WireItem *wire = dynamic_cast(item)) - { - positioned_ids.emplace_back(wire->scenePos(), wire->expressionId()); - } - else if (const WireCellItem *cell = - dynamic_cast(item)) - { - positioned_ids.emplace_back(cell->scenePos(), cell->expressionId()); - } - } - std::sort( - positioned_ids.begin(), positioned_ids.end(), - [](const auto &left, const auto &right) - { - if (!qFuzzyCompare(left.first.y(), right.first.y())) - { - return left.first.y() < right.first.y(); - } - return left.first.x() < right.first.x(); - }); - std::vector ids; - ids.reserve(positioned_ids.size()); - for (const auto &positioned_id : positioned_ids) - { - if (std::find(ids.cbegin(), ids.cend(), positioned_id.second) == ids.cend()) - { - ids.push_back(positioned_id.second); - } - } - return ids; + return !selected_rung_id_.empty() + && editor_service_.findRung(logic_id_, selected_rung_id_) != nullptr + ? selected_rung_id_ : std::string{}; } -std::vector LogicEditorWidget::selectedWireIds() const +bool LogicEditorWidget::hasCopyableSelection() const { - std::vector ids; - for (QGraphicsItem *item : scene_->selectedItems()) - { - if (const WireItem *wire = dynamic_cast(item)) - { - if (std::find(ids.cbegin(), ids.cend(), wire->expressionId()) == ids.cend()) - { - ids.push_back(wire->expressionId()); - } - } - else if (const WireCellItem *cell = - dynamic_cast(item)) - { - if (std::find(ids.cbegin(), ids.cend(), cell->expressionId()) == ids.cend()) - { - ids.push_back(cell->expressionId()); - } - } - } - return ids; + return !selected_cells_.empty() + || !selected_output_rung_ids_.empty() + || !selected_vertical_connection_ids_.empty() + || !selected_row_ids_.empty(); } -std::vector> LogicEditorWidget::selectedEmptySlots() const +LogicClipboardCopyResult LogicEditorWidget::copySelection() const { - std::vector> targets; - for (QGraphicsItem *item : scene_->selectedItems()) + LogicSelectionCopyRequest selection; + selection.cells = selected_cells_; + selection.outputRungIds = selected_output_rung_ids_; + selection.verticalConnectionIds = selected_vertical_connection_ids_; + selection.wholeRungIds = selected_row_ids_; + return editor_service_.copySelection(logic_id_, selection); +} + +LogicPasteTarget LogicEditorWidget::pasteTarget() const +{ + LogicPasteTarget target; + target.rungId = selected_rung_id_.empty() + ? editor_service_.firstRungId(logic_id_) + : selected_rung_id_; + target.output = selected_output_; + target.boundary = selected_boundary_; + target.column = selected_output_ + ? ProjectLimits::kMaximumConditionColumns + : std::max(selected_column_, 0); + return target; +} + +std::string LogicEditorWidget::currentRungId() const +{ + if (!selected_rung_id_.empty() + && editor_service_.findRung(logic_id_, selected_rung_id_) != nullptr) { - if (const EmptySlotItem *slot = dynamic_cast(item)) - { - targets.emplace_back(slot->branchExpressionId(), slot->column()); - } + return selected_rung_id_; } - return targets; + return editor_service_.firstRungId(logic_id_); } -std::vector> LogicEditorWidget::selectedWireCells() const +int LogicEditorWidget::rowAt(const std::string &rung_id) const { - std::vector> cells; - for (QGraphicsItem *item : scene_->selectedItems()) + const ControlLogic *logic = editor_service_.findLogic(logic_id_); + if (logic == nullptr) + { + return -1; + } + for (std::size_t index = 0U; index < logic->rungs.size(); ++index) { - if (const WireCellItem *cell = dynamic_cast(item)) + if (logic->rungs[index].id == rung_id) { - cells.emplace_back(cell->expressionId(), cell->columnOffset()); + return static_cast(index); } } - return cells; + return -1; } -std::vector> LogicEditorWidget::selectedGapCells() const +void LogicEditorWidget::rebuildRowLayout(const ControlLogic &logic) { - std::vector> cells; - for (QGraphicsItem *item : scene_->selectedItems()) + row_layouts_.clear(); + qreal next_top = kTop; + for (std::size_t row = 0U; row < logic.rungs.size(); ++row) { - if (const GapCellItem *cell = dynamic_cast(item)) + bool connected_to_previous = false; + if (row > 0U) { - cells.emplace_back(cell->expressionId(), cell->columnOffset()); + const std::string &upper_id = logic.rungs[row - 1U].id; + const std::string &lower_id = logic.rungs[row].id; + connected_to_previous = std::any_of( + logic.verticalConnections.cbegin(), + logic.verticalConnections.cend(), + [&upper_id, &lower_id](const VerticalConnection &connection) + { + return connection.upperRungId == upper_id + && connection.lowerRungId == lower_id; + }); + } + const bool network_head = row == 0U || !connected_to_previous; + if (network_head) + { + next_top += kCommentBandHeight; } + RowLayout layout; + layout.rungId = logic.rungs[row].id; + layout.row = static_cast(row); + layout.gridTop = next_top; + layout.centerY = next_top + kRowHeight / 2.0 + 6.0; + layout.bottom = next_top + kRowHeight; + layout.networkHead = network_head; + row_layouts_.push_back(std::move(layout)); + next_top += kRowHeight; } - return cells; } -std::vector LogicEditorWidget::selectedBranchIds() const +const LogicEditorWidget::RowLayout *LogicEditorWidget::layoutForRung( + const std::string &rung_id) const { - std::vector ids; - for (QGraphicsItem *item : scene_->selectedItems()) - { - if (const VerticalConnectorItem *connector = - dynamic_cast(item)) + const auto found = std::find_if( + row_layouts_.cbegin(), + row_layouts_.cend(), + [&rung_id](const RowLayout &layout) { - if (std::find(ids.cbegin(), ids.cend(), connector->branchExpressionId()) - == ids.cend()) - { - ids.push_back(connector->branchExpressionId()); - } - } - } - return ids; + return layout.rungId == rung_id; + }); + return found == row_layouts_.cend() ? nullptr : &*found; } -std::string LogicEditorWidget::selectedRungId() const +LogicEditorWidget::Hit LogicEditorWidget::hitAt( + const QPointF &scene_position) const { - std::string rung_id; - for (QGraphicsItem *item : scene_->selectedItems()) + const QList items = scene_->items(scene_position); + const auto hit_for_type = [&items](const QString &wanted) -> Hit { - if (const NodeItem *node = dynamic_cast(item)) - { - if (!rung_id.empty() && rung_id != node->rungId()) - { - return {}; - } - rung_id = node->rungId(); - } - else if (const WireItem *wire = dynamic_cast(item)) - { - if (!rung_id.empty() && rung_id != wire->rungId()) - { - return {}; - } - rung_id = wire->rungId(); - } - else if (const WireCellItem *cell = - dynamic_cast(item)) + for (QGraphicsItem *item : items) { - if (!rung_id.empty() && rung_id != cell->rungId()) + if (item->data(0).toString() != wanted) { - return {}; + continue; } - rung_id = cell->rungId(); - } - else if (const GapCellItem *cell = - dynamic_cast(item)) - { - if (!rung_id.empty() && rung_id != cell->rungId()) + Hit hit; + if (wanted == QStringLiteral("vertical")) { - return {}; + hit.rungId = item->data(2).toString().toStdString(); + hit.column = item->data(3).toInt(); + hit.vertical = true; + hit.lowerRungId = item->data(4).toString().toStdString(); + hit.objectId = item->data(1).toString().toStdString(); } - rung_id = cell->rungId(); - } - else if (const VerticalConnectorItem *connector = - dynamic_cast(item)) - { - if (!rung_id.empty() && rung_id != connector->rungId()) + else if (wanted == QStringLiteral("boundary")) { - return {}; + hit.rungId = item->data(1).toString().toStdString(); + hit.column = item->data(2).toInt(); + hit.boundary = true; } - rung_id = connector->rungId(); - } - else if (const RungItem *rung = dynamic_cast(item)) - { - if (rung_id.empty()) + else if (wanted == QStringLiteral("cell")) { - rung_id = rung->rungId(); + hit.rungId = item->data(1).toString().toStdString(); + hit.column = item->data(2).toInt(); + hit.objectId = item->data(3).toString().toStdString(); + hit.cellKind = static_cast(item->data(4).toInt()); } - } - else if (const EmptySlotItem *slot = - dynamic_cast(item)) - { - if (!rung_id.empty() && rung_id != slot->rungId()) + else if (wanted == QStringLiteral("output")) { - return {}; + hit.rungId = item->data(1).toString().toStdString(); + hit.column = ProjectLimits::kMaximumConditionColumns; + hit.output = true; + hit.objectId = item->data(2).toString().toStdString(); } - rung_id = slot->rungId(); - } - else if (const OutputSlotItem *slot = - dynamic_cast(item)) - { - if (!rung_id.empty() && rung_id != slot->rungId()) + else if (wanted == QStringLiteral("rowHeader")) { - return {}; + hit.rungId = item->data(1).toString().toStdString(); + hit.rowHeader = true; } - rung_id = slot->rungId(); - } - } - return rung_id; -} - -bool LogicEditorWidget::hasSelectedRungItem() const -{ - for (QGraphicsItem *item : scene_->selectedItems()) - { - if (dynamic_cast(item) != nullptr) - { - return true; + return hit; } - } - return false; -} + return {}; + }; -std::vector LogicEditorWidget::selectedRungItemIds() const -{ - std::vector ids; - for (QGraphicsItem *item : scene_->selectedItems()) + Hit hit = hit_for_type(QStringLiteral("rowHeader")); + if (!hit.rungId.empty()) { - if (const auto *rung = dynamic_cast(item)) - { - if (std::find(ids.cbegin(), ids.cend(), rung->rungId()) == ids.cend()) - { - ids.push_back(rung->rungId()); - } - } + return hit; } - return ids; -} - -void LogicEditorWidget::mousePressEvent(QMouseEvent *event) -{ - if (event == nullptr || event->button() != Qt::LeftButton - || !editing_enabled_ || runtime_trace_enabled_) + hit = hit_for_type(QStringLiteral("vertical")); + if (!hit.rungId.empty()) { - QGraphicsView::mousePressEvent(event); - return; + return hit; } - - selection_origin_ = event->pos(); - selection_modifiers_ = event->modifiers(); - selection_dragging_ = false; - - QGraphicsItem *selectable_item = nullptr; - const QPointF scene_position = mapToScene(event->pos()); - const QList items = scene_->items( - scene_position, Qt::IntersectsItemShape, Qt::DescendingOrder); - for (QGraphicsItem *item : items) + hit = hit_for_type(QStringLiteral("boundary")); + if (!hit.rungId.empty()) { - if (item->flags() & QGraphicsItem::ItemIsSelectable) - { - selectable_item = item; - break; - } + return hit; } - - const bool toggle = (selection_modifiers_ & Qt::ControlModifier) != 0; - if (selectable_item != nullptr) + hit = hit_for_type(QStringLiteral("cell")); + if (!hit.rungId.empty()) { - if (toggle) - { - selectable_item->setSelected(!selectable_item->isSelected()); - } - else if (!selectable_item->isSelected()) - { - scene_->clearSelection(); - selectable_item->setSelected(true); - } + return hit; } - else if (!toggle) + hit = hit_for_type(QStringLiteral("output")); + if (!hit.rungId.empty()) { - scene_->clearSelection(); + return hit; } - event->accept(); + return {}; } -void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) +void LogicEditorWidget::rebuildScene() { - if (event == nullptr || !editing_enabled_ || runtime_trace_enabled_ - || !(event->buttons() & Qt::LeftButton)) + scene_->clear(); + row_layouts_.clear(); + const ControlLogic *logic = editor_service_.findLogic(logic_id_); + if (logic == nullptr) { - QGraphicsView::mouseMoveEvent(event); + scene_->setSceneRect(0.0, 0.0, kRightBus + kSceneRightMargin, 120.0); return; } - - if (!selection_dragging_ - && (event->pos() - selection_origin_).manhattanLength() - >= QApplication::startDragDistance()) + rebuildRowLayout(*logic); + const qreal height = row_layouts_.empty() + ? 120.0 : row_layouts_.back().bottom + 24.0; + scene_->setSceneRect( + 0.0, 0.0, kRightBus + kSceneRightMargin, height); + if (row_layouts_.empty()) { - selection_dragging_ = true; - if ((selection_modifiers_ & Qt::ControlModifier) == 0) + return; + } + + std::vector grid_rows; + grid_rows.reserve(logic->rungs.size()); + for (std::size_t row = 0U; row < logic->rungs.size(); ++row) + { + const RowLayout &layout = row_layouts_[row]; + GridRow grid_row; + grid_row.top = layout.gridTop; + grid_rows.push_back(std::move(grid_row)); + } + scene_->addItem(new GridLayerItem(std::move(grid_rows))); + + QPen bus_pen(kLadderColor); + bus_pen.setWidthF(2.2); + bus_pen.setCosmetic(true); + QGraphicsLineItem *left_bus = scene_->addLine( + kLeftBus, + row_layouts_.front().gridTop, + kLeftBus, + row_layouts_.back().bottom, + bus_pen); + left_bus->setZValue(5.0); + QGraphicsLineItem *right_bus = scene_->addLine( + kRightBus, + row_layouts_.front().gridTop, + kRightBus, + row_layouts_.back().bottom, + bus_pen); + right_bus->setZValue(5.0); + + for (std::size_t row = 0U; row < logic->rungs.size(); ++row) + { + const LadderRung &rung = logic->rungs[row]; + const RowLayout &layout = row_layouts_[row]; + QGraphicsRectItem *row_header = scene_->addRect( + QRectF(0.0, layout.gridTop, kLeftBus - 4.0, kRowHeight), + QPen(Qt::NoPen), + QBrush(Qt::transparent)); + row_header->setData(0, QStringLiteral("rowHeader")); + row_header->setData(1, QString::fromStdString(rung.id)); + row_header->setZValue(29.0); + QGraphicsSimpleTextItem *label = scene_->addSimpleText( + QStringLiteral("%1") + .arg(static_cast(row), 3, 10, QLatin1Char('0'))); + label->setBrush(QColor(QStringLiteral("#66757d"))); + label->setPos(10.0, layout.centerY - 10.0); + label->setZValue(6.0); + if (layout.networkHead && !rung.comment.empty()) + { + QGraphicsSimpleTextItem *comment = scene_->addSimpleText( + QString::fromStdString(rung.comment)); + QFont comment_font = comment->font(); + comment_font.setPointSizeF(9.0); + comment->setFont(comment_font); + comment->setBrush(kCommentColor); + comment->setPos( + kLeftBus + 7.0, + layout.gridTop - kCommentBandHeight + 4.0); + comment->setZValue(6.0); + } + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + const LadderCell &cell = rung.cells[static_cast(column)]; + const qreal x = kLeftBus + static_cast(column) * kCellWidth; + const auto input_power = trace_.cellInputPowerValues.find(cell.id); + const bool input_active = runtime_trace_enabled_ + && input_power != trace_.cellInputPowerValues.end() + && input_power->second; + const auto output_power = trace_.cellPowerValues.find(cell.id); + const bool output_active = runtime_trace_enabled_ + && output_power != trace_.cellPowerValues.end() + && output_power->second; + const bool faulted = cell.node.has_value() + && cell.node->id == fault_node_id_; + scene_->addItem(new CellContentItem( + cell, + rung.id, + column, + QPointF(x, layout.gridTop), + input_active, + output_active, + faulted)); + } + for (int boundary = 0; + boundary <= ProjectLimits::kMaximumConditionColumns; + ++boundary) + { + const qreal x = kLeftBus + static_cast(boundary) * kCellWidth; + QPen boundary_pen(Qt::transparent); + boundary_pen.setWidthF(12.0); + QGraphicsLineItem *hit_line = scene_->addLine( + x, + layout.gridTop + 8.0, + x, + layout.bottom - 8.0, + boundary_pen); + hit_line->setData(0, QStringLiteral("boundary")); + hit_line->setData(1, QString::fromStdString(rung.id)); + hit_line->setData(2, boundary); + hit_line->setZValue(30.0); + } + const auto rung_power = trace_.rungValues.find(rung.id); + const bool input_active = runtime_trace_enabled_ + && rung_power != trace_.rungValues.end() && rung_power->second; + bool symbol_active = false; + if (rung.output.has_value()) { - scene_->clearSelection(); + const auto output_value = trace_.nodeValues.find(rung.output->id); + symbol_active = runtime_trace_enabled_ + && output_value != trace_.nodeValues.end() + && output_value->second; } - selection_band_->setGeometry( - QRect(selection_origin_, event->pos()).normalized()); - selection_band_->show(); + const bool faulted = rung.output.has_value() + && rung.output->id == fault_node_id_; + scene_->addItem(new OutputContentItem( + rung, + QPointF(kConditionRight, layout.gridTop), + input_active, + symbol_active, + faulted)); } - if (selection_dragging_) + for (const VerticalConnection &connection : logic->verticalConnections) { - selection_band_->setGeometry( - QRect(selection_origin_, event->pos()).normalized()); - event->accept(); - return; + const RowLayout *upper = layoutForRung(connection.upperRungId); + const RowLayout *lower = layoutForRung(connection.lowerRungId); + if (upper == nullptr || lower == nullptr) + { + continue; + } + const qreal x = kLeftBus + + static_cast(connection.columnBoundary) * kCellWidth; + const auto power = trace_.verticalConnectionValues.find(connection.id); + const bool active = runtime_trace_enabled_ + && power != trace_.verticalConnectionValues.end() && power->second; + scene_->addItem(new VerticalConnectionItem( + connection, x, upper->centerY, lower->centerY, active)); + } + + std::vector selection_rectangles; + for (const std::string &rung_id : selected_row_ids_) + { + const RowLayout *layout = layoutForRung(rung_id); + if (layout != nullptr) + { + selection_rectangles.emplace_back( + 0.0, + layout->gridTop, + kRightBus, + kRowHeight); + } + } + for (const auto &position : selected_cells_) + { + const RowLayout *layout = layoutForRung(position.first); + if (layout != nullptr && position.second >= 0 + && position.second < ProjectLimits::kMaximumConditionColumns) + { + selection_rectangles.emplace_back( + kLeftBus + static_cast(position.second) * kCellWidth, + layout->gridTop, + kCellWidth, + kRowHeight); + } + } + for (const std::string &rung_id : selected_output_rung_ids_) + { + const RowLayout *layout = layoutForRung(rung_id); + if (layout != nullptr) + { + selection_rectangles.emplace_back( + kConditionRight, + layout->gridTop, + kOutputWidth, + kRowHeight); + } + } + for (const std::string &connection_id : selected_vertical_connection_ids_) + { + const VerticalConnection *connection = editor_service_.findConnection( + logic_id_, connection_id); + if (connection == nullptr) + { + continue; + } + const RowLayout *upper = layoutForRung(connection->upperRungId); + const RowLayout *lower = layoutForRung(connection->lowerRungId); + if (upper == nullptr || lower == nullptr) + { + continue; + } + const qreal x = kLeftBus + + static_cast(connection->columnBoundary) * kCellWidth; + selection_rectangles.emplace_back( + x - 7.0, + upper->centerY, + 14.0, + lower->centerY - upper->centerY); + } + if (selected_cell_) + { + const std::pair cursor{ + selected_rung_id_, selected_column_}; + const LadderCell *cell = editor_service_.findCell( + logic_id_, cursor.first, cursor.second); + const bool no_object_selection = selected_cells_.empty() + && selected_output_rung_ids_.empty() + && selected_vertical_connection_ids_.empty(); + if (!containsCell(selected_cells_, cursor) && cell != nullptr + && (cell->kind == LadderCellKind::Gap || no_object_selection)) + { + const RowLayout *layout = layoutForRung(cursor.first); + if (layout != nullptr) + { + selection_rectangles.emplace_back( + kLeftBus + static_cast(cursor.second) * kCellWidth, + layout->gridTop, + kCellWidth, + kRowHeight); + } + } + } + if (selected_output_ + && !containsId(selected_output_rung_ids_, selected_rung_id_)) + { + const RowLayout *layout = layoutForRung(selected_rung_id_); + if (layout != nullptr) + { + selection_rectangles.emplace_back( + kConditionRight, + layout->gridTop, + kOutputWidth, + kRowHeight); + } + } + if (!selection_rectangles.empty()) + { + scene_->addItem(new SelectionOverlayItem( + std::move(selection_rectangles))); } - event->accept(); } -void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) +void LogicEditorWidget::clearObjectSelection() +{ + selected_node_ids_.clear(); + selected_cells_.clear(); + selected_output_rung_ids_.clear(); + selected_vertical_connection_ids_.clear(); + selected_row_ids_.clear(); + selected_vertical_connection_id_.clear(); +} + +void LogicEditorWidget::synchronizeSelectedNodes() { - if (event == nullptr || event->button() != Qt::LeftButton - || !editing_enabled_ || runtime_trace_enabled_) + selected_node_ids_.clear(); + for (const auto &position : selected_cells_) { - QGraphicsView::mouseReleaseEvent(event); + const LadderCell *cell = editor_service_.findCell( + logic_id_, position.first, position.second); + if (cell != nullptr && cell->node.has_value()) + { + selected_node_ids_.push_back(cell->node->id); + } + } + for (const std::string &rung_id : selected_output_rung_ids_) + { + const LadderRung *rung = editor_service_.findRung(logic_id_, rung_id); + if (rung != nullptr && rung->output.has_value()) + { + selected_node_ids_.push_back(rung->output->id); + } + } +} + +void LogicEditorWidget::notifySelectionChanged() +{ + emit nodeSelected(selected_node_ids_.empty() + ? QString{} : QString::fromStdString(selected_node_ids_.front())); +} + +void LogicEditorWidget::selectObject( + const Hit &hit, bool extend_node_selection) +{ + if (hit.rowHeader) + { + if (!extend_node_selection) + { + clearObjectSelection(); + selected_row_ids_.clear(); + } + else if (!selected_cells_.empty() + || !selected_output_rung_ids_.empty() + || !selected_vertical_connection_ids_.empty()) + { + clearObjectSelection(); + } + const auto selected = std::find( + selected_row_ids_.begin(), selected_row_ids_.end(), hit.rungId); + if (extend_node_selection && selected != selected_row_ids_.end()) + { + selected_row_ids_.erase(selected); + } + else if (selected == selected_row_ids_.end()) + { + selected_row_ids_.push_back(hit.rungId); + } + std::sort( + selected_row_ids_.begin(), selected_row_ids_.end(), + [this](const std::string &left, const std::string &right) + { + return rowAt(left) < rowAt(right); + }); + selected_rung_id_ = hit.rungId; + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + synchronizeSelectedNodes(); + rebuildScene(); + notifySelectionChanged(); return; } - if (selection_dragging_) + selected_row_ids_.clear(); + if (!extend_node_selection) { - const QRect view_rect = QRect(selection_origin_, event->pos()).normalized(); - selection_band_->hide(); - QPolygonF scene_polygon; - scene_polygon << mapToScene(view_rect.topLeft()) - << mapToScene(view_rect.topRight()) - << mapToScene(view_rect.bottomRight()) - << mapToScene(view_rect.bottomLeft()); - QPainterPath selection_path; - selection_path.addPolygon(scene_polygon); - const Qt::ItemSelectionOperation operation = - (selection_modifiers_ & Qt::ControlModifier) != 0 - ? Qt::AddToSelection : Qt::ReplaceSelection; - scene_->setSelectionArea( - selection_path, operation, Qt::IntersectsItemShape); - selection_dragging_ = false; + clearObjectSelection(); } - event->accept(); + if (hit.rungId.empty()) + { + if (!extend_node_selection) + { + selected_rung_id_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + } + rebuildScene(); + notifySelectionChanged(); + return; + } + + selected_rung_id_ = hit.rungId; + selected_column_ = hit.column; + selected_cell_ = hit.column >= 0 && !hit.boundary + && !hit.output && !hit.vertical; + selected_output_ = hit.output; + selected_boundary_ = hit.boundary || hit.vertical; + if (hit.vertical) + { + const auto selected = std::find( + selected_vertical_connection_ids_.begin(), + selected_vertical_connection_ids_.end(), + hit.objectId); + if (extend_node_selection + && selected != selected_vertical_connection_ids_.end()) + { + selected_vertical_connection_ids_.erase(selected); + } + else if (selected == selected_vertical_connection_ids_.end()) + { + selected_vertical_connection_ids_.push_back(hit.objectId); + } + } + else if (hit.output && !hit.objectId.empty()) + { + const auto selected = std::find( + selected_output_rung_ids_.begin(), + selected_output_rung_ids_.end(), + hit.rungId); + if (extend_node_selection && selected != selected_output_rung_ids_.end()) + { + selected_output_rung_ids_.erase(selected); + } + else if (selected == selected_output_rung_ids_.end()) + { + selected_output_rung_ids_.push_back(hit.rungId); + } + } + else if (!hit.boundary && !hit.vertical + && hit.cellKind != LadderCellKind::Gap) + { + const std::pair position{hit.rungId, hit.column}; + const auto selected = std::find( + selected_cells_.begin(), selected_cells_.end(), position); + if (extend_node_selection && selected != selected_cells_.end()) + { + selected_cells_.erase(selected); + } + else if (selected == selected_cells_.end()) + { + selected_cells_.push_back(position); + } + } + selected_vertical_connection_id_ = + selected_vertical_connection_ids_.empty() + ? std::string{} : selected_vertical_connection_ids_.back(); + synchronizeSelectedNodes(); + rebuildScene(); + notifySelectionChanged(); } -void LogicEditorWidget::mouseDoubleClickEvent(QMouseEvent *event) +void LogicEditorWidget::selectObjectsInBand( + const QRect &viewport_rect, bool extend_selection) { - if (!editing_enabled_ || runtime_trace_enabled_ || event == nullptr) + if (!selected_row_ids_.empty()) { - QGraphicsView::mouseDoubleClickEvent(event); - return; + selected_row_ids_.clear(); + clearObjectSelection(); + } + if (!extend_selection) + { + clearObjectSelection(); + } + const QPolygonF scene_polygon = mapToScene(viewport_rect.normalized()); + QPainterPath selection_path; + selection_path.addPolygon(scene_polygon); + selection_path.closeSubpath(); + const QList items = scene_->items( + selection_path, + Qt::IntersectsItemShape, + Qt::DescendingOrder); + for (QGraphicsItem *item : items) + { + const QString type = item->data(0).toString(); + if (type == QStringLiteral("cell")) + { + const LadderCellKind kind = static_cast( + item->data(4).toInt()); + if (kind == LadderCellKind::Gap) + { + continue; + } + const std::pair position{ + item->data(1).toString().toStdString(), + item->data(2).toInt()}; + if (!containsCell(selected_cells_, position)) + { + selected_cells_.push_back(position); + } + } + else if (type == QStringLiteral("output") + && !item->data(2).toString().isEmpty()) + { + const std::string rung_id = item->data(1).toString().toStdString(); + if (!containsId(selected_output_rung_ids_, rung_id)) + { + selected_output_rung_ids_.push_back(rung_id); + } + } + else if (type == QStringLiteral("vertical")) + { + const std::string connection_id = + item->data(1).toString().toStdString(); + if (!containsId(selected_vertical_connection_ids_, connection_id)) + { + selected_vertical_connection_ids_.push_back(connection_id); + } + } } - const QPointF scene_position = mapToScene(event->pos()); - QGraphicsItem *item = scene_->itemAt(scene_position, QTransform()); - LogicCommandTarget target; - QPointF center = scene_position; - bool recognized = false; - if (const auto *slot = dynamic_cast(item)) - { - target.kind = slot->branchExpressionId().empty() - ? LogicCommandTargetKind::EmptyColumn - : LogicCommandTargetKind::BranchEmptyColumn; - target.rungId = slot->rungId(); - target.expressionId = slot->branchExpressionId(); - target.column = slot->column(); - center = slot->scenePos(); - recognized = true; - } - else if (const auto *cell = dynamic_cast(item)) - { - target.kind = LogicCommandTargetKind::WireColumn; - target.rungId = cell->rungId(); - target.expressionId = cell->expressionId(); - target.column = cell->columnOffset(); - center = cell->scenePos(); - recognized = true; - } - else if (const auto *cell = dynamic_cast(item)) - { - target.kind = LogicCommandTargetKind::GapColumn; - target.rungId = cell->rungId(); - target.expressionId = cell->expressionId(); - target.column = cell->columnOffset(); - center = cell->scenePos(); - recognized = true; - } - else if (const auto *node = dynamic_cast(item)) + std::sort( + selected_cells_.begin(), + selected_cells_.end(), + [this](const auto &left, const auto &right) + { + const int left_row = rowAt(left.first); + const int right_row = rowAt(right.first); + return left_row != right_row + ? left_row < right_row : left.second < right.second; + }); + std::sort( + selected_output_rung_ids_.begin(), + selected_output_rung_ids_.end(), + [this](const std::string &left, const std::string &right) + { + return rowAt(left) < rowAt(right); + }); + const Hit center_hit = hitAt(mapToScene(viewport_rect.center())); + if (!center_hit.rungId.empty()) + { + selected_rung_id_ = center_hit.rungId; + selected_column_ = center_hit.column; + selected_cell_ = center_hit.column >= 0 && !center_hit.output + && !center_hit.vertical && !center_hit.boundary + && !center_hit.rowHeader; + selected_output_ = center_hit.output; + selected_boundary_ = center_hit.boundary || center_hit.vertical; + } + selected_vertical_connection_id_ = + selected_vertical_connection_ids_.empty() + ? std::string{} : selected_vertical_connection_ids_.back(); + synchronizeSelectedNodes(); + rebuildScene(); + notifySelectionChanged(); +} + +void LogicEditorWidget::beginGesture(const Hit &hit) +{ + if (!editing_enabled_ || hit.rungId.empty() || hit.output) { - target.kind = LogicCommandTargetKind::ExistingNode; - target.rungId = node->rungId(); - target.expressionId = node->nodeId(); - center = node->scenePos(); - recognized = true; + return; } - else if (const auto *slot = dynamic_cast(item)) + gesture_active_ = true; + gesture_origin_ = hit; + gesture_current_ = hit; +} + +void LogicEditorWidget::updateGesture(const Hit &hit) +{ + if (gesture_active_ && !hit.rungId.empty()) { - target.kind = LogicCommandTargetKind::Output; - target.rungId = slot->rungId(); - center = slot->scenePos(); - recognized = true; + gesture_current_ = hit; } +} - if (!recognized) +void LogicEditorWidget::finishGesture(const Hit &hit) +{ + if (!gesture_active_) { - QGraphicsView::mouseDoubleClickEvent(event); return; } - beginCommandInput(target, center); - event->accept(); + gesture_active_ = false; + if (gesture_origin_.rungId.empty() || hit.rungId.empty()) + { + return; + } + LogicEditorResult result; + const bool connected = mouse_wire_mode_ == MouseWireMode::Draw; + if ((gesture_origin_.vertical || gesture_origin_.boundary) + && (hit.vertical || hit.boundary)) + { + if (!connected && gesture_origin_.vertical + && gesture_origin_.objectId == hit.objectId) + { + result = editor_service_.removeVerticalConnections( + logic_id_, {gesture_origin_.objectId}); + } + else if (gesture_origin_.column != hit.column) + { + result = {false, LogicEditorError::InvalidOperation, + "竖线拖动必须保持在同一列边界", {}}; + } + else + { + std::string first_rung = gesture_origin_.rungId; + std::string last_rung = hit.rungId; + if (last_rung.empty() && !gesture_origin_.lowerRungId.empty()) + { + last_rung = gesture_origin_.lowerRungId; + } + result = editor_service_.setVerticalConnectionRange( + logic_id_, first_rung, last_rung, + gesture_origin_.column, connected); + } + } + else if (gesture_origin_.vertical || hit.vertical) + { + result = {false, LogicEditorError::InvalidOperation, + "请从网格边界开始竖向拖动", {}}; + } + else if (gesture_origin_.column == hit.column + && gesture_origin_.rungId != hit.rungId) + { + result = editor_service_.setVerticalConnectionRange( + logic_id_, gesture_origin_.rungId, hit.rungId, + gesture_origin_.column, connected); + } + else if (gesture_origin_.rungId == hit.rungId) + { + result = editor_service_.setHorizontalWireRange( + logic_id_, gesture_origin_.rungId, + gesture_origin_.column, hit.column, connected); + } + else + { + result = {false, LogicEditorError::InvalidOperation, + "画线必须沿同一行或同一列边界进行", {}}; + } + if (!result.succeeded) + { + reportFailure(result); + } + else + { + clearObjectSelection(); + selected_output_ = false; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); + emit graphChanged(); + } } -void LogicEditorWidget::beginCommandInput( - const LogicCommandTarget &target, - const QPointF &scene_center) +void LogicEditorWidget::showCommandEditor(const Hit &hit) { - if (!editing_enabled_ || runtime_trace_enabled_) + if (!editing_enabled_ || hit.rungId.empty() + || hit.boundary || hit.vertical) { return; } + const std::vector parallel_node_ids = selectedNodeIds(); cancelCommandInput(); - command_target_ = target; - command_continuing_ = false; - command_keep_column_ = false; - command_current_rung_id_ = target.rungId; - command_parallel_node_ids_ = selectedNodeIds(); - current_rung_id_ = target.rungId; + command_target_.rungId = hit.rungId; + command_target_.column = hit.column; + const LogicNode *existing = hit.objectId.empty() + ? nullptr : editor_service_.findNode(logic_id_, hit.objectId); + if (existing != nullptr) + { + command_target_.kind = LogicCommandTargetKind::ExistingNode; + } + else if (hit.output) + { + command_target_.kind = LogicCommandTargetKind::Output; + } + else + { + const LadderCell *cell = editor_service_.findCell( + logic_id_, hit.rungId, hit.column); + command_target_.kind = cell != nullptr + && cell->kind == LadderCellKind::Wire + ? LogicCommandTargetKind::WireColumn + : LogicCommandTargetKind::GapColumn; + } + command_target_.expressionId = hit.objectId; + command_parallel_node_ids_ = parallel_node_ids; + QString initial_text; - if (target.kind == LogicCommandTargetKind::ExistingNode) + if (existing != nullptr && existing->isConfigured()) { - const LogicNode *node = editor_service_.findNode( - logic_id_, target.expressionId); - if (node != nullptr && node->isConfigured()) - { - initial_text = logicCommandText(node->config); - } + initial_text = logicCommandText(existing->config); } command_editor_->setStyleSheet(QString{}); command_editor_->setToolTip( - tr("触点使用 M 地址,比较使用 D 地址+常量,MOV/ADD/SUB 使用 D 地址")); + tr("触点使用 M 地址,比较和数据指令使用 D 地址")); command_editor_->setText(initial_text); command_editor_->setVisible(true); - positionCommandInput(scene_center); + QPointF center; + if (!findCommandTargetCenter(command_target_, ¢er)) + { + cancelCommandInput(); + return; + } + positionCommandInput(center); command_editor_->raise(); command_editor_->setFocus(Qt::MouseFocusReason); command_editor_->selectAll(); @@ -1932,64 +1858,47 @@ void LogicEditorWidget::commitCommandInput() } LogicCommandRequest request; request.logicId = logic_id_; - request.text = command_editor_->text().toUtf8().toStdString(); + request.text = command_editor_->text().trimmed().toStdString(); request.target = command_target_; - request.continuing = command_continuing_; - request.currentRungId = command_current_rung_id_; request.parallelNodeIds = command_parallel_node_ids_; const LogicCommandResult result = command_service_.execute(request); if (!result.succeeded) { command_editor_->setStyleSheet( QStringLiteral("QLineEdit { border: 2px solid #c5362e; }")); - command_editor_->setToolTip(QString::fromUtf8(result.message.c_str())); + command_editor_->setToolTip(QString::fromStdString(result.message)); command_editor_->setFocus(Qt::OtherFocusReason); command_editor_->selectAll(); - emit editorError(QString::fromUtf8(result.message.c_str())); + emit editorError(QString::fromStdString(result.message)); return; } - command_continuing_ = true; - command_keep_column_ = result.opcode == LogicCommandOpcode::Or - || result.opcode == LogicCommandOpcode::OrInverse; - command_current_rung_id_ = result.rungId.empty() - ? command_current_rung_id_ : result.rungId; - command_parallel_node_ids_.clear(); - reloadLogic(); - selectCommandResult(result); command_editor_->setStyleSheet(QString{}); command_editor_->setToolTip( - tr("触点使用 M 地址,比较使用 D 地址+常量,MOV/ADD/SUB 使用 D 地址")); - - if (command_target_.kind == LogicCommandTargetKind::ExistingNode) + tr("触点使用 M 地址,比较和数据指令使用 D 地址")); + command_parallel_node_ids_.clear(); + if (!result.hasNextCursor) { + selectNode(result.id); + emit graphChanged(); cancelCommandInput(); return; } - if (isLoadCommandOpcode(result.opcode)) - { - command_target_.kind = LogicCommandTargetKind::EmptyColumn; - command_target_.rungId = command_current_rung_id_; - command_target_.expressionId.clear(); - command_target_.column = 0; - command_keep_column_ = false; - } - - if (result.opcode == LogicCommandOpcode::Output - || result.opcode == LogicCommandOpcode::Set - || result.opcode == LogicCommandOpcode::Reset - || result.opcode == LogicCommandOpcode::Move - || result.opcode == LogicCommandOpcode::Add - || result.opcode == LogicCommandOpcode::Subtract) + moveToCursor(result.nextCursor); + emit graphChanged(); + command_target_ = commandTargetForCursor(result.nextCursor); + QPointF center; + if (command_target_.rungId.empty() + || !findCommandTargetCenter(command_target_, ¢er)) { cancelCommandInput(); return; } - if (!beginNextCommandInput()) - { - cancelCommandInput(); - } + command_editor_->clear(); + positionCommandInput(center); + command_editor_->raise(); + command_editor_->setFocus(Qt::OtherFocusReason); } void LogicEditorWidget::cancelCommandInput() @@ -1997,83 +1906,13 @@ void LogicEditorWidget::cancelCommandInput() if (command_editor_ != nullptr) { command_editor_->clear(); - command_editor_->setVisible(false); command_editor_->setStyleSheet(QString{}); + command_editor_->setVisible(false); } - command_continuing_ = false; - command_keep_column_ = false; - command_current_rung_id_.clear(); + command_target_ = {}; command_parallel_node_ids_.clear(); } -bool LogicEditorWidget::findCommandTargetCenter( - const LogicCommandTarget &target, - QPointF *scene_center) const -{ - if (scene_center == nullptr) - { - return false; - } - for (QGraphicsItem *item : scene_->items()) - { - if (const auto *slot = dynamic_cast(item)) - { - if (target.kind == (slot->branchExpressionId().empty() - ? LogicCommandTargetKind::EmptyColumn - : LogicCommandTargetKind::BranchEmptyColumn) - && slot->rungId() == target.rungId - && slot->branchExpressionId() == target.expressionId - && slot->column() == target.column) - { - *scene_center = slot->scenePos(); - return true; - } - } - else if (const auto *cell = dynamic_cast(item)) - { - if (target.kind == LogicCommandTargetKind::WireColumn - && cell->rungId() == target.rungId - && cell->expressionId() == target.expressionId - && cell->columnOffset() == target.column) - { - *scene_center = cell->scenePos(); - return true; - } - } - else if (const auto *cell = dynamic_cast(item)) - { - if (target.kind == LogicCommandTargetKind::GapColumn - && cell->rungId() == target.rungId - && cell->expressionId() == target.expressionId - && cell->columnOffset() == target.column) - { - *scene_center = cell->scenePos(); - return true; - } - } - else if (const auto *node = dynamic_cast(item)) - { - if (target.kind == LogicCommandTargetKind::ExistingNode - && node->rungId() == target.rungId - && node->nodeId() == target.expressionId) - { - *scene_center = node->scenePos(); - return true; - } - } - else if (const auto *slot = dynamic_cast(item)) - { - if (target.kind == LogicCommandTargetKind::Output - && slot->rungId() == target.rungId) - { - *scene_center = slot->scenePos(); - return true; - } - } - } - return false; -} - void LogicEditorWidget::positionCommandInput(const QPointF &scene_center) { if (command_editor_ == nullptr) @@ -2081,7 +1920,7 @@ void LogicEditorWidget::positionCommandInput(const QPointF &scene_center) return; } const QPoint view_center = mapFromScene(scene_center); - const int width = 320; + const int width = 360; const int height = 38; const QRect bounds = viewport()->rect().adjusted(4, 4, -4, -4); const int left = std::clamp( @@ -2092,254 +1931,355 @@ void LogicEditorWidget::positionCommandInput(const QPointF &scene_center) view_center.y() - height / 2, bounds.top(), std::max(bounds.top(), bounds.bottom() - height + 1)); - command_editor_->setGeometry( - left, - top, - width, - height); + command_editor_->setGeometry(left, top, width, height); } -bool LogicEditorWidget::beginNextCommandInput() +bool LogicEditorWidget::findCommandTargetCenter( + const LogicCommandTarget &target, + QPointF *scene_center) const { - if (!command_editor_->isVisible()) + if (scene_center == nullptr) { return false; } - if (command_target_.kind == LogicCommandTargetKind::Output) + const RowLayout *layout = layoutForRung(target.rungId); + if (layout == nullptr) { return false; } - const int next_column = command_target_.column - + (command_keep_column_ ? 0 : 1); - if (next_column >= ProjectLimits::kMaximumConditionColumns) + if (target.kind == LogicCommandTargetKind::Output) { - LogicCommandTarget output_target; - output_target.kind = LogicCommandTargetKind::Output; - output_target.rungId = command_current_rung_id_; - QPointF output_center; - if (!findCommandTargetCenter(output_target, &output_center)) - { - return false; - } - command_target_ = output_target; - command_editor_->clear(); - positionCommandInput(output_center); - command_editor_->setFocus(Qt::OtherFocusReason); + *scene_center = QPointF( + kConditionRight + kOutputWidth / 2.0, + layout->centerY); return true; } - - LogicCommandTarget next = command_target_; - next.column = next_column; - QPointF next_center; - if (!findCommandTargetCenter(next, &next_center)) + int column = target.column; + if (target.kind == LogicCommandTargetKind::ExistingNode) { - next.kind = next.expressionId.empty() - ? LogicCommandTargetKind::EmptyColumn - : LogicCommandTargetKind::BranchEmptyColumn; - if (!findCommandTargetCenter(next, &next_center)) + const LadderRung *rung = editor_service_.findRung( + logic_id_, target.rungId); + if (rung == nullptr) { - next.kind = LogicCommandTargetKind::EmptyColumn; - next.expressionId.clear(); - if (!findCommandTargetCenter(next, &next_center)) + return false; + } + if (rung->output.has_value() + && rung->output->id == target.expressionId) + { + *scene_center = QPointF( + kConditionRight + kOutputWidth / 2.0, + layout->centerY); + return true; + } + const auto found = std::find_if( + rung->cells.cbegin(), rung->cells.cend(), + [&target](const LadderCell &cell) { - if (next_column < ProjectLimits::kMaximumConditionColumns - 1) - { - return false; - } - LogicCommandTarget output_target; - output_target.kind = LogicCommandTargetKind::Output; - output_target.rungId = command_current_rung_id_; - if (!findCommandTargetCenter(output_target, &next_center)) - { - return false; - } - command_target_ = output_target; - command_editor_->clear(); - positionCommandInput(next_center); - command_editor_->setFocus(Qt::OtherFocusReason); - return true; - } + return cell.node.has_value() + && cell.node->id == target.expressionId; + }); + if (found == rung->cells.cend()) + { + return false; } + column = static_cast(std::distance(rung->cells.cbegin(), found)); } - command_target_ = next; - command_editor_->clear(); - positionCommandInput(next_center); - command_editor_->setFocus(Qt::OtherFocusReason); + if (column < 0 || column >= ProjectLimits::kMaximumConditionColumns) + { + return false; + } + *scene_center = QPointF( + kLeftBus + (static_cast(column) + 0.5) * kCellWidth, + layout->centerY); return true; } -void LogicEditorWidget::selectCommandResult(const LogicCommandResult &result) +LogicCommandTarget LogicEditorWidget::commandTargetForCursor( + const LogicEditCursor &cursor) const { - if (result.id.empty()) + LogicCommandTarget target; + target.rungId = cursor.rungId; + target.column = cursor.column; + if (cursor.output) { - return; + target.kind = LogicCommandTargetKind::Output; + return target; } - selectNode(result.id); - emit nodeSelected(QString::fromUtf8(result.id.c_str())); - emit graphChanged(); + const LadderCell *cell = editor_service_.findCell( + logic_id_, cursor.rungId, cursor.column); + if (cell != nullptr && cell->node.has_value()) + { + target.kind = LogicCommandTargetKind::ExistingNode; + target.expressionId = cell->node->id; + } + else + { + target.kind = cell != nullptr && cell->kind == LadderCellKind::Wire + ? LogicCommandTargetKind::WireColumn + : LogicCommandTargetKind::GapColumn; + } + return target; } -LogicEditorResult LogicEditorWidget::addRung() +LogicEditCursor LogicEditorWidget::conditionInsertionCursor() const { - const LogicEditorResult result = editor_service_.addRung(logic_id_); - if (result.succeeded) + const ControlLogic *logic = editor_service_.findLogic(logic_id_); + if (logic == nullptr || logic->rungs.empty()) { - current_rung_id_ = result.id; - reloadLogic(); - emit graphChanged(); + return {}; } - else + const std::string rung_id = currentRungId(); + if (selected_output_) { - reportFailure(result); + return {rung_id, ProjectLimits::kMaximumConditionColumns, true}; } - return result; + if (selected_cell_ && selected_column_ >= 0) + { + int target_column = selected_column_; + const LadderCell *cell = editor_service_.findCell( + logic_id_, rung_id, selected_column_); + if (cell != nullptr && cell->kind == LadderCellKind::Node) + { + ++target_column; + } + return { + rung_id, + std::min(target_column, ProjectLimits::kMaximumConditionColumns), + target_column >= ProjectLimits::kMaximumConditionColumns}; + } + const LadderRung *rung = editor_service_.findRung(logic_id_, rung_id); + if (rung != nullptr) + { + const auto empty = std::find_if( + rung->cells.cbegin(), rung->cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Gap; + }); + if (empty != rung->cells.cend()) + { + return { + rung_id, + static_cast(std::distance(rung->cells.cbegin(), empty)), + false}; + } + } + return {rung_id, ProjectLimits::kMaximumConditionColumns, true}; } -LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) +void LogicEditorWidget::moveToCursor(const LogicEditCursor &cursor) +{ + clearObjectSelection(); + selected_rung_id_ = cursor.rungId; + selected_column_ = cursor.column; + selected_cell_ = !cursor.output; + selected_output_ = cursor.output; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); + + const LogicCommandTarget target = commandTargetForCursor(cursor); + QPointF center; + if (findCommandTargetCenter(target, ¢er)) + { + ensureVisible( + QRectF(center.x() - kCellWidth / 2.0, + center.y() - kRowHeight / 2.0, + cursor.output ? kOutputWidth : kCellWidth, + kRowHeight), + 24, + 24); + } +} + +LogicEditorResult LogicEditorWidget::finishCursorEdit( + const LogicEditResult &result) { - const std::vector> selected_empty_slots = - selectedEmptySlots(); - const std::vector> selected_wire_cells = - selectedWireCells(); - const std::vector> selected_gap_cells = - selectedGapCells(); - const std::vector selected_expressions = selectedExpressionIds(); - const std::vector selected_wires = selectedWireIds(); - const std::vector selected_ids = selectedNodeIds(); - LogicEditorResult result; - if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U - || selected_gap_cells.size() > 1U - || (!selected_empty_slots.empty() - && (!selected_wire_cells.empty() - || !selected_gap_cells.empty() - || !selected_expressions.empty() || !selected_ids.empty())) - || (!selected_wire_cells.empty() - && (!selected_gap_cells.empty() - || !selected_ids.empty() || selected_expressions.size() > 1U)) - || (!selected_gap_cells.empty() - && (!selected_ids.empty() || !selected_expressions.empty()))) + if (!result.edit.succeeded) { - result = {false, LogicEditorError::InvalidOperation, - "插入条件时只能选择一个网格或条件对象", {}}; + reportFailure(result.edit); + return result.edit; } - else if (selected_empty_slots.size() == 1U) + moveToCursor(result.nextCursor); + emit graphChanged(); + return result.edit; +} + +void LogicEditorWidget::reportFailure(const LogicEditorResult &result) +{ + emit editorError(QString::fromStdString(result.message)); +} + +void LogicEditorWidget::mousePressEvent(QMouseEvent *event) +{ + const Hit hit = hitAt(mapToScene(event->pos())); + setFocus(Qt::MouseFocusReason); + if (event->button() == Qt::LeftButton + && mouse_wire_mode_ != MouseWireMode::Select && editing_enabled_) { - const auto &slot = selected_empty_slots.front(); - result = slot.first.empty() - ? editor_service_.insertConditionAtColumn( - logic_id_, currentRungId(), slot.second, config) - : editor_service_.insertConditionInBranchAtColumn( - logic_id_, currentRungId(), slot.first, slot.second, config); + beginGesture(hit); } - else if (selected_wire_cells.size() == 1U) + else if (event->button() == Qt::LeftButton + && mouse_wire_mode_ == MouseWireMode::Select) { - result = editor_service_.replaceWireColumnWithCondition( - logic_id_, - currentRungId(), - selected_wire_cells.front().first, - selected_wire_cells.front().second, - config); + selection_pressed_ = true; + selection_dragging_ = false; + selection_origin_ = event->pos(); + selection_modifiers_ = event->modifiers(); + selection_band_->setGeometry(QRect(selection_origin_, QSize{})); + selection_band_->hide(); } - else if (selected_gap_cells.size() == 1U) + else { - result = editor_service_.replaceGapColumnWithCondition( - logic_id_, - currentRungId(), - selected_gap_cells.front().first, - selected_gap_cells.front().second, - config); + QGraphicsView::mousePressEvent(event); + return; } - else if (selected_expressions.size() > 1U || selected_wires.size() > 1U) + event->accept(); +} + +void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (mouse_wire_mode_ == MouseWireMode::Select && selection_pressed_) { - result = {false, LogicEditorError::InvalidOperation, - "串联插入或替换横线时只能选择一个条件对象", {}}; + if (!selection_dragging_ + && (event->pos() - selection_origin_).manhattanLength() + >= QApplication::startDragDistance()) + { + selection_dragging_ = true; + selection_band_->show(); + } + if (selection_dragging_) + { + selection_band_->setGeometry( + QRect(selection_origin_, event->pos()).normalized()); + } + } + else + { + updateGesture(hitAt(mapToScene(event->pos()))); } - else if (selected_wires.size() == 1U) + event->accept(); +} + +void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event) +{ + const Hit hit = hitAt(mapToScene(event->pos())); + if (event->button() == Qt::LeftButton + && mouse_wire_mode_ == MouseWireMode::Select && selection_pressed_) { - result = editor_service_.replaceWireWithCondition( - logic_id_, currentRungId(), selected_wires.front(), config); + const bool extend = selection_modifiers_.testFlag(Qt::ControlModifier); + selection_pressed_ = false; + selection_band_->hide(); + if (selection_dragging_) + { + selectObjectsInBand( + QRect(selection_origin_, event->pos()).normalized(), extend); + } + else + { + selectObject(hit, extend); + } + selection_dragging_ = false; } - else if (selected_ids.size() == 1U) + else if (mouse_wire_mode_ != MouseWireMode::Select) { - const LogicNode *selected_node = editor_service_.findNode( - logic_id_, selected_ids.front()); - result = selected_node != nullptr && selected_node->isCondition() - ? editor_service_.insertConditionAfter( - logic_id_, currentRungId(), selected_ids.front(), config) - : editor_service_.appendCondition(logic_id_, currentRungId(), config); + finishGesture(hit); } else { - result = editor_service_.appendCondition(logic_id_, currentRungId(), config); + QGraphicsView::mouseReleaseEvent(event); + return; } - if (result.succeeded) + event->accept(); +} + +void LogicEditorWidget::mouseDoubleClickEvent(QMouseEvent *event) +{ + showCommandEditor(hitAt(mapToScene(event->pos()))); + event->accept(); +} + +void LogicEditorWidget::resizeEvent(QResizeEvent *event) +{ + QGraphicsView::resizeEvent(event); + if (command_editor_ != nullptr && command_editor_->isVisible()) { - reloadLogic(); - selectExpression(result.id); - emit graphChanged(); + QPointF center; + if (findCommandTargetCenter(command_target_, ¢er)) + { + positionCommandInput(center); + } } - else +} + +void LogicEditorWidget::scrollContentsBy(int dx, int dy) +{ + QGraphicsView::scrollContentsBy(dx, dy); + if (command_editor_ != nullptr && command_editor_->isVisible()) { - reportFailure(result); + QPointF center; + if (findCommandTargetCenter(command_target_, ¢er)) + { + positionCommandInput(center); + } } - return result; } -LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &config) +bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) { - const std::string rung_id = selectedRungId(); - const std::vector> selected_empty_slots = - selectedEmptySlots(); - const std::vector> selected_wire_cells = - selectedWireCells(); - const std::vector> selected_gap_cells = - selectedGapCells(); - const std::vector selected_ids = selectedNodeIds(); - std::vector condition_ids; - for (const std::string &node_id : selected_ids) + if (watched == command_editor_ && event != nullptr) { - const LogicNode *node = editor_service_.findNode(logic_id_, node_id); - if (node != nullptr && node->isCondition()) + if (event->type() == QEvent::KeyPress) { - condition_ids.push_back(node_id); + const auto *key_event = static_cast(event); + if (key_event->key() == Qt::Key_Escape) + { + cancelCommandInput(); + return true; + } + } + else if (event->type() == QEvent::FocusOut) + { + QTimer::singleShot( + 0, + this, + [this] + { + if (command_editor_ == nullptr + || !command_editor_->isVisible() + || command_editor_->hasFocus()) + { + return; + } + QWidget *focus = QApplication::focusWidget(); + QWidget *popup = command_completer_ == nullptr + ? nullptr : command_completer_->popup(); + if (popup != nullptr && popup->isVisible() + && focus != nullptr + && (focus == popup || popup->isAncestorOf(focus))) + { + return; + } + cancelCommandInput(); + }); } } - LogicEditorResult result; - if (!selected_gap_cells.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "断路网格不能建立并联支路,请先补横线或插入触点", {}}; - } - else if (!selected_empty_slots.empty() - && (!selected_ids.empty() || !selected_wire_cells.empty())) - { - result = {false, LogicEditorError::InvalidOperation, - "并联支路不能混合选择空白网格和逻辑对象", {}}; - } - else if (!selected_empty_slots.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "空白网格没有可并联的逻辑,请先选择触点或横线", {}}; - } - else if (!selected_wire_cells.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "并联支路需要选择触点;横线网格请使用竖线连接", {}}; - } - else if (rung_id.empty() || condition_ids.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "请在同一网络中选择要并联的连续节点", {}}; - } - else - { - result = editor_service_.addParallelBranch( - logic_id_, rung_id, condition_ids, config); - } + return QGraphicsView::eventFilter(watched, event); +} + +LogicEditorResult LogicEditorWidget::addRung() +{ + const LogicEditorResult result = editor_service_.addRung(logic_id_); if (result.succeeded) { - reloadLogic(); - selectExpression(result.id); + clearObjectSelection(); + selected_rung_id_ = result.id; + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); emit graphChanged(); } else @@ -2349,39 +2289,22 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co return result; } -LogicEditorResult LogicEditorWidget::addHorizontalWire() +LogicEditorResult LogicEditorWidget::insertRung(bool after) { - const std::vector selected_ids = selectedExpressionIds(); - const std::vector> selected_gap_cells = - selectedGapCells(); - LogicEditorResult result; - if (selected_ids.size() > 1U || selected_gap_cells.size() > 1U - || (!selected_ids.empty() && !selected_gap_cells.empty())) - { - result = {false, LogicEditorError::InvalidOperation, - "插入横线时只能选择一个条件对象", {}}; - } - else if (selected_gap_cells.size() == 1U) - { - result = editor_service_.replaceGapColumnWithWire( - logic_id_, - currentRungId(), - selected_gap_cells.front().first, - selected_gap_cells.front().second); - } - else if (selected_ids.size() == 1U) - { - result = editor_service_.insertWireAfter( - logic_id_, currentRungId(), selected_ids.front()); - } - else - { - result = editor_service_.appendWire(logic_id_, currentRungId()); - } + const std::string reference = selected_rung_id_; + const LogicEditorResult result = reference.empty() + ? editor_service_.addRung(logic_id_) + : editor_service_.insertRung(logic_id_, reference, after); if (result.succeeded) { - reloadLogic(); - selectWire(result.id); + clearObjectSelection(); + selected_rung_id_ = result.id; + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); emit graphChanged(); } else @@ -2391,58 +2314,24 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire() return result; } -LogicEditorResult LogicEditorWidget::addVerticalWire() +LogicEditorResult LogicEditorWidget::deleteRung() { - const std::string rung_id = selectedRungId(); - const std::vector> selected_empty_slots = - selectedEmptySlots(); - const std::vector> selected_wire_cells = - selectedWireCells(); - const std::vector> selected_gap_cells = - selectedGapCells(); - const std::vector selected_nodes = selectedNodeIds(); - const std::vector selected_ids = selectedExpressionIds(); - LogicEditorResult result; - if (!selected_gap_cells.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "断路网格不能建立竖线连接,请先补横线", {}}; - } - else if (!selected_empty_slots.empty() - && (!selected_wire_cells.empty() || !selected_nodes.empty())) - { - result = {false, LogicEditorError::InvalidOperation, - "竖线不能混合选择空白网格和逻辑对象", {}}; - } - else if (!selected_empty_slots.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "空白网格没有可连接的逻辑,请先插入触点或横线", {}}; - } - else if (!selected_wire_cells.empty() && !selected_nodes.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "竖线只能选择连续触点或同一条横线的网格", {}}; - } - else if (rung_id.empty() || selected_ids.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "请在同一网络中选择要连接的连续条件或横线", {}}; - } - else if (!selected_wire_cells.empty()) - { - result = editor_service_.addParallelWireBranchAtCells( - logic_id_, rung_id, selected_wire_cells); - } - else + const std::string rung_id = selected_rung_id_; + if (rung_id.empty()) { - result = editor_service_.addParallelWireBranch( - logic_id_, rung_id, selected_ids); + return {false, LogicEditorError::RungNotFound, "请先选择要删除的行", {}}; } + const LogicEditorResult result = editor_service_.removeRung(logic_id_, rung_id); if (result.succeeded) { - reloadLogic(); - selectWire(result.id); + clearObjectSelection(); + selected_rung_id_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); emit graphChanged(); } else @@ -2452,29 +2341,29 @@ LogicEditorResult LogicEditorWidget::addVerticalWire() return result; } -LogicEditorResult LogicEditorWidget::deleteHorizontalWire() +LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) { - const std::vector> wire_cells = - selectedWireCells(); - const std::vector wire_ids = selectedWireIds(); - const std::string rung_id = selectedRungId(); - if (wire_ids.empty() || rung_id.empty()) + return finishCursorEdit(editor_service_.applyConditionAndAdvance( + logic_id_, conditionInsertionCursor(), config, false)); +} + +LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &config) +{ + if (selected_node_ids_.empty() || selectedRungId().empty()) { - const LogicEditorResult result = { + const LogicEditorResult result{ false, LogicEditorError::InvalidOperation, - "请先选择要删除的横线", + "请先选择同一行中要并联的连续条件节点", {}}; reportFailure(result); return result; } - const LogicEditorResult result = !wire_cells.empty() - ? editor_service_.disconnectWireCells(logic_id_, rung_id, wire_cells) - : editor_service_.disconnectWires(logic_id_, rung_id, wire_ids); + const LogicEditorResult result = editor_service_.addParallelBranch( + logic_id_, selectedRungId(), selected_node_ids_, config, false); if (result.succeeded) { - reloadLogic(); - emit nodeSelected({}); + selectNode(result.id); emit graphChanged(); } else @@ -2484,35 +2373,68 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire() return result; } -LogicEditorResult LogicEditorWidget::deleteVerticalWire() +LogicEditorResult LogicEditorWidget::addHorizontalWire() { - const std::vector branch_ids = selectedBranchIds(); - if (branch_ids.empty()) + const ControlLogic *logic = editor_service_.findLogic(logic_id_); + if (logic == nullptr) + { + const LogicEditorResult result{ + false, LogicEditorError::LogicNotFound, "未找到控制逻辑", {}}; + reportFailure(result); + return result; + } + if (!logic->rungs.empty() + && (selectedRungId().empty() || !selected_cell_)) { - const LogicEditorResult result = { + const LogicEditorResult result{ false, LogicEditorError::InvalidOperation, - "请先选择要删除的竖线连接", + "请先选择一个条件网格,再插入横线", {}}; reportFailure(result); return result; } - const std::string rung_id = selectedRungId(); - LogicEditorResult result; - if (rung_id.empty()) + LogicEditCursor cursor = logic->rungs.empty() + ? LogicEditCursor{} : conditionInsertionCursor(); + if (cursor.output) { - result = {false, LogicEditorError::InvalidOperation, - "竖线连接必须位于同一网络", {}}; + const LogicEditorResult result{ + false, + LogicEditorError::InvalidOperation, + "条件区已经填满,请在输出槽配置输出指令", + {}}; + reportFailure(result); + return result; } - else + return finishCursorEdit(editor_service_.applyWireAndAdvance( + logic_id_, cursor)); +} + +LogicEditorResult LogicEditorWidget::addVerticalWire() +{ + const ControlLogic *logic = editor_service_.findLogic(logic_id_); + if (logic == nullptr || logic->rungs.size() < 2U) { - result = editor_service_.removeExpressions( - logic_id_, rung_id, branch_ids); + return {false, LogicEditorError::InvalidOperation, "至少需要两行才能连接竖线", {}}; } + const std::string upper = selectedRungId(); + if (upper.empty() || selected_column_ < 0 + || selected_column_ > ProjectLimits::kMaximumConditionColumns) + { + return {false, LogicEditorError::InvalidOperation, + "请先选择一个列边界或网格,再插入竖线", {}}; + } + const int index = rowAt(upper); + if (index < 0 || static_cast(index + 1) >= logic->rungs.size()) + { + return {false, LogicEditorError::InvalidOperation, "请选择非末行作为连接起点", {}}; + } + const LogicEditorResult result = editor_service_.setVerticalConnection( + logic_id_, upper, logic->rungs[static_cast(index + 1)].id, + selected_column_, true); if (result.succeeded) { - reloadLogic(); - emit nodeSelected({}); + rebuildScene(); emit graphChanged(); } else @@ -2522,15 +2444,35 @@ LogicEditorResult LogicEditorWidget::deleteVerticalWire() return result; } -LogicEditorResult LogicEditorWidget::setOutput( - const LogicNodeConfig &config, bool configured) +LogicEditorResult LogicEditorWidget::deleteHorizontalWire() { - const LogicEditorResult result = editor_service_.setOutput( - logic_id_, currentRungId(), config, configured); + const std::string rung_id = selectedRungId(); + if (rung_id.empty() || !selected_cell_ || selected_column_ < 0 + || selected_column_ >= ProjectLimits::kMaximumConditionColumns) + { + return {false, LogicEditorError::InvalidOperation, + "请先选择要删除的横线网格", {}}; + } + const LadderCell *cell = editor_service_.findCell( + logic_id_, rung_id, selected_column_); + if (cell == nullptr || cell->kind != LadderCellKind::Wire) + { + return {false, LogicEditorError::InvalidOperation, + "请选择一格横线后再删除", {}}; + } + const LogicEditorResult result = editor_service_.setHorizontalWireRange( + logic_id_, rung_id, selected_column_, selected_column_, false); if (result.succeeded) { - reloadLogic(); - selectNode(result.id); + selected_cells_.erase( + std::remove( + selected_cells_.begin(), + selected_cells_.end(), + std::make_pair(rung_id, selected_column_)), + selected_cells_.end()); + synchronizeSelectedNodes(); + rebuildScene(); + notifySelectionChanged(); emit graphChanged(); } else @@ -2540,82 +2482,31 @@ LogicEditorResult LogicEditorWidget::setOutput( return result; } -LogicEditorResult LogicEditorWidget::pasteConditionNodes( - const std::vector &nodes) +LogicEditorResult LogicEditorWidget::deleteVerticalWire() { - const std::vector> selected_empty_slots = - selectedEmptySlots(); - const std::vector> selected_wire_cells = - selectedWireCells(); - const std::vector> selected_gap_cells = - selectedGapCells(); - const std::vector selected_expressions = selectedExpressionIds(); - const std::vector selected_wires = selectedWireIds(); - const std::vector selected_ids = selectedNodeIds(); - LogicEditorResult result; - LogicConditionPasteTarget target; - if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U - || selected_gap_cells.size() > 1U - || (!selected_empty_slots.empty() - && (!selected_wire_cells.empty() - || !selected_gap_cells.empty() - || !selected_expressions.empty() || !selected_ids.empty())) - || (!selected_wire_cells.empty() - && (!selected_gap_cells.empty() - || !selected_ids.empty() || selected_expressions.size() > 1U)) - || (!selected_gap_cells.empty() - && (!selected_ids.empty() || !selected_expressions.empty())) - || selected_expressions.size() > 1U || selected_wires.size() > 1U - || selected_ids.size() > 1U) - { - result = {false, LogicEditorError::InvalidOperation, - "粘贴梯形图条件时只能选择一个目标位置", {}}; - } - else + if (selected_vertical_connection_id_.empty()) { - if (selected_empty_slots.size() == 1U) - { - const auto &slot = selected_empty_slots.front(); - target.kind = slot.first.empty() - ? LogicConditionPasteTargetKind::EmptyColumn - : LogicConditionPasteTargetKind::BranchEmptyColumn; - target.expressionId = slot.first; - target.column = slot.second; - } - else if (selected_wire_cells.size() == 1U) - { - target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; - target.expressionId = selected_wire_cells.front().first; - target.column = selected_wire_cells.front().second; - } - else if (selected_gap_cells.size() == 1U) - { - target.kind = LogicConditionPasteTargetKind::ReplaceGapColumn; - target.expressionId = selected_gap_cells.front().first; - target.column = selected_gap_cells.front().second; - } - else if (selected_wires.size() == 1U) - { - target.kind = LogicConditionPasteTargetKind::ReplaceWire; - target.expressionId = selected_wires.front(); - } - else if (selected_ids.size() == 1U) - { - const LogicNode *selected_node = editor_service_.findNode( - logic_id_, selected_ids.front()); - if (selected_node != nullptr && selected_node->isCondition()) - { - target.kind = LogicConditionPasteTargetKind::AfterNode; - target.expressionId = selected_ids.front(); - } - } - result = editor_service_.pasteConditionNodes( - logic_id_, currentRungId(), nodes, target); + return {false, LogicEditorError::ConnectionNotFound, "请选择要删除的竖线", {}}; } + const LogicEditorResult result = editor_service_.removeVerticalConnections( + logic_id_, {selected_vertical_connection_id_}); if (result.succeeded) { - reloadLogic(); - selectNode(result.id); + selected_vertical_connection_ids_.erase( + std::remove( + selected_vertical_connection_ids_.begin(), + selected_vertical_connection_ids_.end(), + selected_vertical_connection_id_), + selected_vertical_connection_ids_.end()); + selected_vertical_connection_id_ = + selected_vertical_connection_ids_.empty() + ? std::string{} : selected_vertical_connection_ids_.back(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); emit graphChanged(); } else @@ -2625,232 +2516,129 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( return result; } -LogicEditorResult LogicEditorWidget::deleteSelected() +LogicEditorResult LogicEditorWidget::setOutput( + const LogicNodeConfig &config, bool configured) { - const std::vector selected_rungs = selectedRungItemIds(); - const std::vector wire_ids = selectedWireIds(); - const std::vector> wire_cells = - selectedWireCells(); - const std::vector> gap_cells = - selectedGapCells(); - const std::vector branch_ids = selectedBranchIds(); - const std::vector node_ids = selectedNodeIds(); - LogicEditorResult result; + return finishCursorEdit(editor_service_.applyOutputAndAdvance( + logic_id_, + {currentRungId(), ProjectLimits::kMaximumConditionColumns, true}, + config, + configured)); +} - // 框选网络时,网络本身优先于其中的节点、横线和断路占位删除 - if (!selected_rungs.empty()) +LogicClipboardPasteResult LogicEditorWidget::pasteClipboard( + const LogicClipboardFragment &fragment) +{ + LogicClipboardPasteResult result = editor_service_.pasteClipboard( + logic_id_, fragment, pasteTarget()); + if (result.edit.succeeded) { - result = editor_service_.removeRungs(logic_id_, selected_rungs); - if (result.succeeded - && std::find( - selected_rungs.cbegin(), selected_rungs.cend(), - current_rung_id_) != selected_rungs.cend()) + clearObjectSelection(); + selected_cells_ = result.selection.cells; + selected_output_rung_ids_ = result.selection.outputRungIds; + selected_vertical_connection_ids_ = + result.selection.verticalConnectionIds; + selected_row_ids_ = result.wholeRungIds; + if (!selected_row_ids_.empty()) { - current_rung_id_ = editor_service_.firstRungId(logic_id_); + selected_rung_id_ = selected_row_ids_.back(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; } - } - else - { - bool changed = false; - bool failed = false; - const auto apply = [&result, &changed](const LogicEditorResult &operation) - { - if (!operation.succeeded) - { - result = operation; - return false; - } - changed = true; - result = operation; - return true; - }; - - // 竖线和横线必须落在同一网络;节点可以跨网络批量删除 - const std::string rung_id = selectedRungId(); - if (!branch_ids.empty()) + else if (!selected_cells_.empty()) { - if (rung_id.empty()) - { - result = {false, LogicEditorError::InvalidOperation, - "所选竖线连接必须位于同一网络", {}}; - failed = true; - } - else if (!apply(editor_service_.removeExpressions( - logic_id_, rung_id, branch_ids))) - { - failed = true; - } + selected_rung_id_ = selected_cells_.front().first; + selected_column_ = selected_cells_.front().second; + selected_cell_ = true; + selected_output_ = false; + selected_boundary_ = false; } - - if (!failed) + else if (!selected_output_rung_ids_.empty()) { - std::vector existing_node_ids; - for (const std::string &node_id : node_ids) - { - if (editor_service_.findNode(logic_id_, node_id) != nullptr) - { - existing_node_ids.push_back(node_id); - } - } - if (!existing_node_ids.empty() - && !apply(editor_service_.removeNodes(logic_id_, existing_node_ids))) - { - failed = true; - } + selected_rung_id_ = selected_output_rung_ids_.front(); + selected_column_ = ProjectLimits::kMaximumConditionColumns; + selected_cell_ = false; + selected_output_ = true; + selected_boundary_ = false; } - - if (!failed && !wire_ids.empty() && !rung_id.empty()) + else if (!selected_vertical_connection_ids_.empty()) { - std::vector> existing_wire_cells; - for (const auto &cell : wire_cells) - { - const ConditionExpression *expression = editor_service_.findExpression( - logic_id_, rung_id, cell.first); - if (expression != nullptr - && expression->kind == ConditionExpressionKind::Wire) - { - existing_wire_cells.push_back(cell); - } - } - std::vector existing_wire_ids; - for (const std::string &wire_id : wire_ids) - { - const ConditionExpression *expression = editor_service_.findExpression( - logic_id_, rung_id, wire_id); - if (expression != nullptr - && expression->kind == ConditionExpressionKind::Wire) - { - existing_wire_ids.push_back(wire_id); - } - } - if (!existing_wire_ids.empty()) + const VerticalConnection *connection = editor_service_.findConnection( + logic_id_, selected_vertical_connection_ids_.front()); + if (connection != nullptr) { - const LogicEditorResult wire_result = !existing_wire_cells.empty() - ? editor_service_.disconnectWireCells( - logic_id_, rung_id, existing_wire_cells) - : editor_service_.disconnectWires( - logic_id_, rung_id, existing_wire_ids); - apply(wire_result); - failed = !result.succeeded; + selected_rung_id_ = connection->upperRungId; + selected_column_ = connection->columnBoundary; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = true; } } - - // Gap/空网格本身已经是空白,混合框选时直接忽略,不再弹出误导性提示 - if (!failed && !changed && branch_ids.empty() && node_ids.empty() - && wire_ids.empty() - && (!gap_cells.empty() || !selectedEmptySlots().empty())) - { - result = {true, LogicEditorError::None, {}, {}}; - changed = true; - } - if (!failed && !changed) - { - result = {false, LogicEditorError::InvalidOperation, - "请先选择要删除的逻辑节点或网络", {}}; - } - } - if (result.succeeded) - { - reloadLogic(); - emit nodeSelected({}); + selected_vertical_connection_id_ = + selected_vertical_connection_ids_.empty() + ? std::string{} : selected_vertical_connection_ids_.back(); + synchronizeSelectedNodes(); + rebuildScene(); + notifySelectionChanged(); emit graphChanged(); } else { - reportFailure(result); + reportFailure(result.edit); } return result; } -void LogicEditorWidget::resizeEvent(QResizeEvent *event) -{ - QGraphicsView::resizeEvent(event); - if (command_editor_ != nullptr && command_editor_->isVisible()) - { - QPointF center; - if (findCommandTargetCenter(command_target_, ¢er)) - { - positionCommandInput(center); - } - } -} - -void LogicEditorWidget::scrollContentsBy(int dx, int dy) +LogicEditorResult LogicEditorWidget::deleteSelected() { - QGraphicsView::scrollContentsBy(dx, dy); - Q_UNUSED(dx); - Q_UNUSED(dy); - if (command_editor_ != nullptr && command_editor_->isVisible()) + LogicSelectionDeleteRequest selection; + selection.cells = selected_cells_; + selection.outputRungIds = selected_output_rung_ids_; + selection.verticalConnectionIds = selected_vertical_connection_ids_; + if (selection.cells.empty() && selection.outputRungIds.empty() + && selection.verticalConnectionIds.empty() && selected_cell_ + && !selected_rung_id_.empty() && selected_column_ >= 0 + && selected_column_ < ProjectLimits::kMaximumConditionColumns) { - QPointF center; - if (findCommandTargetCenter(command_target_, ¢er)) + const LadderCell *cell = editor_service_.findCell( + logic_id_, selected_rung_id_, selected_column_); + if (cell != nullptr && cell->kind != LadderCellKind::Gap) { - positionCommandInput(center); + selection.cells.push_back({ + selected_rung_id_, selected_column_}); } } -} - -bool LogicEditorWidget::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == command_editor_ && event != nullptr) + if (selection.cells.empty() && selection.outputRungIds.empty() + && selection.verticalConnectionIds.empty()) { - if (event->type() == QEvent::KeyPress) - { - const auto *key_event = static_cast(event); - if (key_event->key() == Qt::Key_Escape) - { - cancelCommandInput(); - return true; - } - } - else if (event->type() == QEvent::FocusOut) - { - // 延迟到焦点真正切换后判断,避免点击补全弹窗时误取消输入 - QTimer::singleShot(0, this, [this] - { - if (command_editor_ == nullptr || !command_editor_->isVisible() - || command_editor_->hasFocus()) - { - return; - } - QWidget *focus = QApplication::focusWidget(); - QWidget *popup = command_completer_ == nullptr - ? nullptr : command_completer_->popup(); - if (popup != nullptr && popup->isVisible() - && focus != nullptr - && (focus == popup || popup->isAncestorOf(focus))) - { - return; - } - cancelCommandInput(); - }); - } + const LogicEditorResult result{ + false, + LogicEditorError::InvalidOperation, + "请先选择逻辑指令、横线、输出块或竖线;整行请使用 Shift+Delete", + {}}; + reportFailure(result); + return result; } - return QGraphicsView::eventFilter(watched, event); -} -void LogicEditorWidget::handleSelectionChanged() -{ - const std::string rung_id = selectedRungId(); - if (!rung_id.empty()) + const LogicEditorResult result = editor_service_.deleteSelection( + logic_id_, selection); + if (result.succeeded) { - current_rung_id_ = rung_id; + clearObjectSelection(); + selected_rung_id_.clear(); + selected_column_ = -1; + selected_cell_ = false; + selected_output_ = false; + selected_boundary_ = false; + rebuildScene(); + notifySelectionChanged(); + emit graphChanged(); } - emit nodeSelected(QString::fromStdString(selectedNodeId())); -} - -void LogicEditorWidget::reportFailure(const LogicEditorResult &result) -{ - emit editorError(QString::fromStdString(result.message)); -} - -std::string LogicEditorWidget::currentRungId() const -{ - const std::string selected = selectedRungId(); - if (!selected.empty()) + else { - return selected; + reportFailure(result); } - return current_rung_id_.empty() - ? editor_service_.firstRungId(logic_id_) : current_rung_id_; + return result; } diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 0a03f95..0524e0e 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -1,11 +1,3 @@ -/** - * @file logic_editor_widget.h - * @brief 定义结构化梯形图编辑和运行轨迹画布 - * @version 1.0.0 - * @author suyu - * @date 2026-08-22 - */ - #pragma once #include "domain/control_logic_model.h" @@ -14,6 +6,7 @@ #include "services/software_logic_executor.h" #include +#include #include #include @@ -27,180 +20,149 @@ class QMouseEvent; class QRubberBand; class QResizeEvent; -/** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ class LogicEditorWidget final : public QGraphicsView { Q_OBJECT - public: - /** 梯形图中的条件、输出、横线和辅助选择图元类型 */ - class NodeItem; - class WireItem; - class WireCellItem; - class GapCellItem; - class VerticalConnectorItem; - class RungItem; - class EmptySlotItem; - class OutputSlotItem; + enum class MouseWireMode { Select, Draw, Erase }; - /** 创建梯形图画布并绑定编辑服务 */ explicit LogicEditorWidget( LogicEditorService &editor_service, QWidget *parent = nullptr); - /** 切换当前显示的控制逻辑;不存在时显示空场景 */ void setLogicId(const std::string &logic_id); - /** 设置是否允许编辑梯形图 */ void setEditingEnabled(bool enabled); - /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ + void setMouseWireMode(MouseWireMode mode); + MouseWireMode mouseWireMode() const; void setRuntimeTrace( const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); - /** 清除当前运行轨迹和故障节点标记 */ void clearRuntimeTrace(); - /** 返回当前是否正在显示运行轨迹 */ bool runtimeTraceEnabled() const; - /** 根据当前逻辑模型重建梯形图场景 */ void reloadLogic(); - /** 选中指定节点并滚动到可见区域 */ + void clearSelection(); void selectNode(const std::string &node_id); - /** 返回当前选中的单个节点标识 */ + /** 选中并滚动到语法错误位置,column 使用界面显示的 1 基列号 */ + void focusSyntaxLocation(const std::string &rung_id, int column); std::string selectedNodeId() const; - /** 返回当前选中的多个节点标识 */ std::vector selectedNodeIds() const; - /** 返回当前选中的网络标识 */ std::string selectedRungId() const; + bool hasCopyableSelection() const; + LogicClipboardCopyResult copySelection() const; + LogicPasteTarget pasteTarget() const; - /** 新增一个空的梯形图网络 */ LogicEditorResult addRung(); - /** 将当前选择转换为服务调用并新增串联条件 */ + LogicEditorResult insertRung(bool after); + LogicEditorResult deleteRung(); LogicEditorResult addCondition(const LogicNodeConfig &config); - /** 将当前选择转换为服务调用并新增并联支路 */ LogicEditorResult addParallelBranch(const LogicNodeConfig &config); - /** 在当前选择位置新增横线 */ LogicEditorResult addHorizontalWire(); - /** 在当前选择位置新增竖线 */ LogicEditorResult addVerticalWire(); - /** 删除当前选择位置的横线 */ LogicEditorResult deleteHorizontalWire(); - /** 删除当前选择位置的竖线 */ LogicEditorResult deleteVerticalWire(); - /** 设置当前网络的输出线圈 */ LogicEditorResult setOutput( const LogicNodeConfig &config, bool configured = false); - /** 按当前画布选择位置粘贴一组连续条件 */ - LogicEditorResult pasteConditionNodes(const std::vector &nodes); - /** 删除当前选中的节点、线段或网络 */ + LogicClipboardPasteResult pasteClipboard( + const LogicClipboardFragment &fragment); LogicEditorResult deleteSelected(); signals: - /** 当前选中的节点发生变化时发出 */ void nodeSelected(const QString &node_id); - /** 梯形图模型成功发生变化时发出 */ void graphChanged(); - /** 编辑服务操作失败时发出可显示的错误信息 */ void editorError(const QString &message); protected: - /** 在任意网络区域开始鼠标框选 */ void mousePressEvent(QMouseEvent *event) override; - /** 更新鼠标框选区域 */ void mouseMoveEvent(QMouseEvent *event) override; - /** 完成鼠标框选并提交场景选择 */ void mouseReleaseEvent(QMouseEvent *event) override; - /** 在条件网格或输出槽双击时打开命令输入框 */ void mouseDoubleClickEvent(QMouseEvent *event) override; - /** 在窗口大小变化后重新布局梯形图场景 */ void resizeEvent(QResizeEvent *event) override; - /** 滚动画布时同步命令输入框位置 */ void scrollContentsBy(int dx, int dy) override; - /** 处理命令输入框的回车、取消和按键事件 */ bool eventFilter(QObject *watched, QEvent *event) override; private: - /** 处理图形场景选择变化 */ - void handleSelectionChanged(); - /** 把编辑服务结果转换为错误信号 */ + struct RowLayout + { + std::string rungId; + int row = -1; + qreal gridTop = 0.0; + qreal centerY = 0.0; + qreal bottom = 0.0; + bool networkHead = false; + }; + + struct Hit + { + std::string rungId; + int column = -1; + LadderCellKind cellKind = LadderCellKind::Gap; + bool output = false; + bool vertical = false; + bool boundary = false; + bool rowHeader = false; + std::string lowerRungId; + std::string objectId; + }; + void reportFailure(const LogicEditorResult &result); - /** 返回当前选中的网络标识 */ std::string currentRungId() const; - /** 选中指定表达式图元 */ - void selectExpression(const std::string &expression_id); - /** 选中指定横线图元 */ - void selectWire(const std::string &wire_id); - /** 收集当前选中的表达式标识 */ - std::vector selectedExpressionIds() const; - /** 收集当前选中的横线标识 */ - std::vector selectedWireIds() const; - /** 收集当前选中的并联支路标识 */ - std::vector selectedBranchIds() const; - /** 收集当前选中的空网格位置 */ - std::vector> selectedEmptySlots() const; - /** 收集当前选中的横线网格位置 */ - std::vector> selectedWireCells() const; - /** 收集当前选中的显式断路网格位置 */ - std::vector> selectedGapCells() const; - /** 判断当前是否选中了网络内的图元 */ - bool hasSelectedRungItem() const; - /** 收集当前明确选中的网络图元 */ - std::vector selectedRungItemIds() const; - /** 打开指定目标位置的命令输入框 */ - void beginCommandInput( - const LogicCommandTarget &target, - const QPointF &scene_center); - /** 提交当前命令输入;失败时保留输入框并标红 */ + Hit hitAt(const QPointF &scene_position) const; + void beginGesture(const Hit &hit); + void updateGesture(const Hit &hit); + void finishGesture(const Hit &hit); + void showCommandEditor(const Hit &hit); void commitCommandInput(); - /** 关闭命令输入框并清理临时状态 */ void cancelCommandInput(); - /** 根据网络、列和支路标识查找下一输入位置 */ - bool beginNextCommandInput(); - /** 按目标定位输入框的屏幕矩形 */ void positionCommandInput(const QPointF &scene_center); - /** 返回当前场景目标的中心位置 */ bool findCommandTargetCenter( const LogicCommandTarget &target, QPointF *scene_center) const; - /** 将成功命令选中并刷新属性面板 */ - void selectCommandResult(const LogicCommandResult &result); + LogicCommandTarget commandTargetForCursor( + const LogicEditCursor &cursor) const; + LogicEditCursor conditionInsertionCursor() const; + void moveToCursor(const LogicEditCursor &cursor); + LogicEditorResult finishCursorEdit(const LogicEditResult &result); + void rebuildScene(); + void selectObject(const Hit &hit, bool extend_node_selection = false); + void selectObjectsInBand(const QRect &viewport_rect, bool extend_selection); + void clearObjectSelection(); + void synchronizeSelectedNodes(); + void notifySelectionChanged(); + void rebuildRowLayout(const ControlLogic &logic); + const RowLayout *layoutForRung(const std::string &rung_id) const; + int rowAt(const std::string &rung_id) const; - /** 梯形图编辑服务,不由控件拥有 */ LogicEditorService &editor_service_; - /** 命令语解析和结构化编辑适配服务 */ LogicCommandService command_service_; - /** 承载梯形图图元的场景 */ QGraphicsScene *scene_ = nullptr; - /** 当前显示的控制逻辑标识 */ std::string logic_id_; - /** 当前选中的网络标识 */ - std::string current_rung_id_; - /** 最近一次收到的运行轨迹 */ + std::string selected_rung_id_; + std::vector selected_node_ids_; + std::vector> selected_cells_; + std::vector selected_output_rung_ids_; + std::vector selected_vertical_connection_ids_; + std::vector selected_row_ids_; + std::vector row_layouts_; LogicTraceSnapshot trace_; - /** 最近一次运行故障节点标识 */ std::string fault_node_id_; - /** 是否显示运行轨迹 */ bool runtime_trace_enabled_ = false; - /** 是否允许编辑梯形图 */ bool editing_enabled_ = true; - /** 鼠标框选覆盖层 */ + MouseWireMode mouse_wire_mode_ = MouseWireMode::Select; + int selected_column_ = -1; + bool selected_cell_ = false; + bool selected_output_ = false; + bool selected_boundary_ = false; + std::string selected_vertical_connection_id_; QRubberBand *selection_band_ = nullptr; - /** 框选起点(视口坐标) */ QPoint selection_origin_; - /** 框选时是否已超过拖拽阈值 */ + bool selection_pressed_ = false; bool selection_dragging_ = false; - /** 框选起始时的键盘修饰键 */ Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; - /** 命令输入框,仅在编辑态临时创建 */ + bool gesture_active_ = false; + Hit gesture_origin_; + Hit gesture_current_; QLineEdit *command_editor_ = nullptr; - /** 命令输入补全器 */ QCompleter *command_completer_ = nullptr; - /** 当前命令输入目标 */ LogicCommandTarget command_target_; - /** 是否沿当前命令序列继续输入 */ - bool command_continuing_ = false; - /** OR/ORI 只增加支路,不推进主表达式列 */ - bool command_keep_column_ = false; - /** 连续输入对应的当前网络 */ - std::string command_current_rung_id_; - /** 编辑已有网络 OR 时保留的选择范围 */ std::vector command_parallel_node_ids_; }; diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index fbebaf4..816b060 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -40,6 +40,8 @@ #include #include #include +#include +#include #include #include #include @@ -55,11 +57,16 @@ #include #include #include +#include #include namespace { +constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1; +constexpr int kSyntaxRungIdRole = Qt::UserRole + 2; +constexpr int kSyntaxColumnRole = Qt::UserRole + 3; + bool isTextEditingObject(QObject *object) { QWidget *widget = qobject_cast(object); @@ -612,6 +619,33 @@ void MainWindow::configureActions() this, &MainWindow::deleteActiveSelection); connect(ui_->clearSelectionAction, &QAction::triggered, this, &MainWindow::clearActiveSelection); + connect(ui_->syntaxCheckAction, &QAction::triggered, + this, &MainWindow::runLogicSyntaxCheck); + connect(ui_->doubleCoilCheckAction, &QAction::triggered, + this, &MainWindow::runDoubleCoilCheck); + connect(ui_->outputList, &QListWidget::itemDoubleClicked, + this, + [this](QListWidgetItem *item) + { + if (item == nullptr) + { + return; + } + const std::string logic_id = toUtf8( + item->data(kSyntaxLogicIdRole).toString()); + const std::string rung_id = toUtf8( + item->data(kSyntaxRungIdRole).toString()); + if (logic_id.empty() || rung_id.empty()) + { + return; + } + focusLogicSyntaxLocation({ + logic_id, + rung_id, + 0, + 0, + item->data(kSyntaxColumnRole).toInt()}); + }); connect(ui_->addButtonAction, &QAction::triggered, this, [this] { addHmiControl(HmiControlType::Button); }); @@ -641,6 +675,44 @@ void MainWindow::configureActions() connect(ui_->addRungAction, &QAction::triggered, this, &MainWindow::addLogicRung); + connect(ui_->insertRungAboveAction, &QAction::triggered, + this, &MainWindow::insertLogicRungAbove); + connect(ui_->insertRungBelowAction, &QAction::triggered, + this, &MainWindow::insertLogicRungBelow); + connect(ui_->deleteRungAction, &QAction::triggered, + this, &MainWindow::deleteLogicRung); + connect(ui_->mouseDrawWireAction, &QAction::triggered, + this, + [this](bool checked) + { + if (checked) + { + const QSignalBlocker blocker(ui_->mouseEraseWireAction); + ui_->mouseEraseWireAction->setChecked(false); + } + logic_editor_widget_->setMouseWireMode( + checked + ? LogicEditorWidget::MouseWireMode::Draw + : ui_->mouseEraseWireAction->isChecked() + ? LogicEditorWidget::MouseWireMode::Erase + : LogicEditorWidget::MouseWireMode::Select); + }); + connect(ui_->mouseEraseWireAction, &QAction::triggered, + this, + [this](bool checked) + { + if (checked) + { + const QSignalBlocker blocker(ui_->mouseDrawWireAction); + ui_->mouseDrawWireAction->setChecked(false); + } + logic_editor_widget_->setMouseWireMode( + checked + ? LogicEditorWidget::MouseWireMode::Erase + : ui_->mouseDrawWireAction->isChecked() + ? LogicEditorWidget::MouseWireMode::Draw + : LogicEditorWidget::MouseWireMode::Select); + }); connect(ui_->insertHorizontalWireAction, &QAction::triggered, this, &MainWindow::addLogicHorizontalWire); connect(ui_->insertVerticalWireAction, &QAction::triggered, @@ -911,6 +983,11 @@ void MainWindow::configureAppearance() ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); ui_->deleteControlAction->setIcon(makeUiIcon(UiIcon::Delete)); ui_->addRungAction->setIcon(makeUiIcon(UiIcon::AddRung)); + ui_->insertRungAboveAction->setIcon(makeUiIcon(UiIcon::AddRung)); + ui_->insertRungBelowAction->setIcon(makeUiIcon(UiIcon::AddRung)); + ui_->deleteRungAction->setIcon(makeUiIcon(UiIcon::Delete)); + ui_->mouseDrawWireAction->setIcon(makeUiIcon(UiIcon::MouseDrawWire)); + ui_->mouseEraseWireAction->setIcon(makeUiIcon(UiIcon::MouseEraseWire)); ui_->parallelInsertAction->setIcon(makeUiIcon(UiIcon::ParallelBranch)); ui_->insertHorizontalWireAction->setIcon(makeUiIcon(UiIcon::HorizontalWire)); ui_->insertVerticalWireAction->setIcon(makeUiIcon(UiIcon::VerticalWire)); @@ -928,6 +1005,8 @@ void MainWindow::configureAppearance() ui_->addSubAction->setIcon(makeUiIcon(UiIcon::Subtract)); ui_->addCompareAction->setIcon(makeUiIcon(UiIcon::Compare)); ui_->editRungCommentAction->setIcon(makeUiIcon(UiIcon::Comment)); + ui_->syntaxCheckAction->setIcon(makeUiIcon(UiIcon::SyntaxCheck)); + ui_->doubleCoilCheckAction->setIcon(makeUiIcon(UiIcon::Coil)); ui_->deleteLogicAction->setIcon(makeUiIcon(UiIcon::Delete)); ui_->editorTabWidget->setTabIcon(0, makeUiIcon(UiIcon::HmiPage)); ui_->editorTabWidget->setTabIcon(1, makeUiIcon(UiIcon::Logic)); @@ -992,6 +1071,12 @@ void MainWindow::configureLogicEditor() layout->addWidget(logic_editor_widget_); property_panel_controller_->bindEditorWidgets( *hmi_editor_widget_, *logic_editor_widget_); + connect( + hmi_editor_widget_, &HmiEditorWidget::controlSelected, + this, [this](const QString &) { updateEditActions(); }); + connect( + logic_editor_widget_, &LogicEditorWidget::nodeSelected, + this, [this](const QString &) { updateEditActions(); }); } void MainWindow::configureRuntimeMonitor() @@ -1275,57 +1360,29 @@ void MainWindow::copyActiveSelection() } else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) { - const std::vector ids = logic_editor_widget_->selectedNodeIds(); - if (!ids.empty()) + LogicClipboardCopyResult result = logic_editor_widget_->copySelection(); + if (!result.copy.succeeded) { - const std::string rung_id = logic_editor_widget_->selectedRungId(); - if (rung_id.empty()) - { - statusBar()->showMessage(tr("只能复制同一网络中的梯形图指令"), 3000); - return; - } - std::vector nodes; - nodes.reserve(ids.size()); - bool all_conditions = true; - for (const std::string &id : ids) - { - const LogicNode *node = logic_editor_service_.findNode( - current_logic_id_, id); - if (node == nullptr) - { - return; - } - all_conditions = all_conditions && node->isCondition(); - nodes.push_back(*node); - } - if (all_conditions - && logic_editor_service_.areConditionNodesContiguous( - current_logic_id_, rung_id, ids)) - { - editor_clipboard_ = LogicNodesClipboardData{std::move(nodes)}; - statusBar()->showMessage(tr("已复制 %1 个梯形图条件").arg(ids.size()), 3000); - } - else if (ids.size() == 1U && nodes.front().isOutput()) - { - editor_clipboard_ = LogicOutputClipboardData{nodes.front()}; - statusBar()->showMessage(tr("已复制梯形图输出指令"), 3000); - } - else - { - statusBar()->showMessage( - tr("只能复制同一串联层级中连续的条件,或单个输出指令"), 3000); - } - updateEditActions(); + statusBar()->showMessage(fromUtf8(result.copy.message), 5000); return; } - - const std::string rung_id = logic_editor_widget_->selectedRungId(); - const LadderRung *rung = logic_editor_service_.findRung( - current_logic_id_, rung_id); - if (rung != nullptr) + const bool whole_rows = + result.fragment.mode == LogicClipboardMode::WholeRows; + const std::size_t object_count = result.fragment.cells.size() + + result.fragment.outputs.size() + + result.fragment.verticalConnections.size(); + editor_clipboard_ = LogicClipboardData{std::move(result.fragment)}; + if (whole_rows) { - editor_clipboard_ = LogicRungClipboardData{*rung}; - statusBar()->showMessage(tr("已复制整条梯形图网络"), 3000); + const auto *data = std::get_if(&editor_clipboard_); + statusBar()->showMessage( + tr("已复制 %1 行梯形图").arg(data->fragment.rows.size()), + 3000); + } + else + { + statusBar()->showMessage( + tr("已复制 %1 个梯形图对象").arg(object_count), 3000); } } updateEditActions(); @@ -1362,37 +1419,24 @@ void MainWindow::pasteActiveSelection() } else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) { - LogicEditorResult result; - if (const auto *data = std::get_if(&editor_clipboard_)) - { - result = logic_editor_widget_->pasteConditionNodes(data->nodes); - if (result.succeeded) - { - selected_logic_node_id_ = result.id; - showLogicNodeProperties(result.id); - } - } - else if (const auto *data = std::get_if(&editor_clipboard_)) - { - result = logic_editor_widget_->setOutput( - data->output.config, data->output.configured); - } - else if (const auto *data = std::get_if(&editor_clipboard_)) + const auto *data = std::get_if(&editor_clipboard_); + if (data == nullptr) { - result = logic_editor_service_.pasteRung(current_logic_id_, data->rung); - if (result.succeeded) - { - logic_editor_widget_->reloadLogic(); - } + return; } - if (!result.succeeded) + const LogicClipboardPasteResult result = + logic_editor_widget_->pasteClipboard(data->fragment); + if (!result.edit.succeeded) { - if (!result.message.empty()) + if (!result.edit.message.empty()) { - showProjectResult(tr("粘贴梯形图"), fromUtf8(result.message), false); + showProjectResult( + tr("粘贴梯形图"), fromUtf8(result.edit.message), false); } return; } + selected_logic_node_id_ = logic_editor_widget_->selectedNodeId(); + showLogicNodeProperties(selected_logic_node_id_); refreshProjectUi(); statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000); } @@ -1421,7 +1465,7 @@ void MainWindow::clearActiveSelection() } else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) { - logic_editor_widget_->scene()->clearSelection(); + logic_editor_widget_->clearSelection(); selected_logic_node_id_.clear(); showLogicNodeProperties({}); } @@ -1439,19 +1483,32 @@ void MainWindow::updateEditActions() ui_->redoAction->setEnabled( editable && ((hmi_active && hmi_editor_service_.canRedo()) || (logic_active && logic_editor_service_.canRedo()))); - ui_->copyAction->setEnabled(editable && (hmi_active || logic_active)); + const bool hmi_selection = hmi_active + && !hmi_editor_widget_->selectedControlIds().empty(); + const bool logic_selection = logic_active + && logic_editor_widget_->hasCopyableSelection(); + ui_->copyAction->setEnabled( + editable && (hmi_selection || logic_selection)); const bool hmi_clipboard = std::holds_alternative(editor_clipboard_); - const bool logic_clipboard = std::holds_alternative(editor_clipboard_) - || std::holds_alternative(editor_clipboard_) - || std::holds_alternative(editor_clipboard_); + const bool logic_clipboard = + std::holds_alternative(editor_clipboard_); ui_->pasteAction->setEnabled(editable && ((hmi_active && hmi_clipboard) || (logic_active && logic_clipboard))); ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); ui_->insertVerticalWireAction->setEnabled(editable && logic_active); + ui_->insertRungAboveAction->setEnabled(editable && logic_active); + ui_->insertRungBelowAction->setEnabled(editable && logic_active); + ui_->deleteRungAction->setEnabled(editable && logic_active); ui_->deleteHorizontalWireAction->setEnabled(editable && logic_active); ui_->deleteVerticalWireAction->setEnabled(editable && logic_active); + ui_->mouseDrawWireAction->setEnabled(editable && logic_active); + ui_->mouseEraseWireAction->setEnabled(editable && logic_active); + ui_->syntaxCheckAction->setEnabled( + editable && !current_logic_id_.empty()); + ui_->doubleCoilCheckAction->setEnabled( + editable && !current_logic_id_.empty()); } void MainWindow::clearEditorHistories() @@ -1645,7 +1702,7 @@ void MainWindow::addLogicVerticalWire() selected_logic_node_id_.clear(); showLogicNodeProperties({}); refreshProjectUi(); - statusBar()->showMessage(tr("已建立横线旁路和竖线连接"), 3000); + statusBar()->showMessage(tr("竖线连接已建立"), 3000); } void MainWindow::deleteLogicHorizontalWire() @@ -1673,7 +1730,7 @@ void MainWindow::deleteLogicVerticalWire() selected_logic_node_id_.clear(); showLogicNodeProperties({}); refreshProjectUi(); - statusBar()->showMessage(tr("竖线及对应并联支路已删除"), 3000); + statusBar()->showMessage(tr("竖线已删除,网络已拆分"), 3000); } void MainWindow::setLogicOutput(const LogicNodeConfig &config) @@ -1725,6 +1782,48 @@ void MainWindow::addLogicRung() statusBar()->showMessage(tr("已新建网络"), 3000); } +void MainWindow::insertLogicRungAbove() +{ + const LogicEditorResult result = logic_editor_widget_->insertRung(false); + if (!result.succeeded) + { + showProjectResult(tr("上方插入行"), fromUtf8(result.message), false); + return; + } + selected_logic_node_id_.clear(); + showLogicNodeProperties({}); + refreshProjectUi(); + statusBar()->showMessage(tr("已在当前行上方插入空白行"), 3000); +} + +void MainWindow::insertLogicRungBelow() +{ + const LogicEditorResult result = logic_editor_widget_->insertRung(true); + if (!result.succeeded) + { + showProjectResult(tr("下方插入行"), fromUtf8(result.message), false); + return; + } + selected_logic_node_id_.clear(); + showLogicNodeProperties({}); + refreshProjectUi(); + statusBar()->showMessage(tr("已在当前行下方插入空白行"), 3000); +} + +void MainWindow::deleteLogicRung() +{ + const LogicEditorResult result = logic_editor_widget_->deleteRung(); + if (!result.succeeded) + { + showProjectResult(tr("删除行"), fromUtf8(result.message), false); + return; + } + selected_logic_node_id_.clear(); + showLogicNodeProperties({}); + refreshProjectUi(); + statusBar()->showMessage(tr("当前行已删除,竖线连接已重新整理"), 3000); +} + void MainWindow::editSelectedRungComment() { const std::string rung_id = logic_editor_widget_->selectedRungId(); @@ -1763,6 +1862,62 @@ void MainWindow::editSelectedRungComment() statusBar()->showMessage(tr("网络注释已更新"), 3000); } +void MainWindow::runLogicSyntaxCheck() +{ + const LogicSyntaxCheckResult result = logic_editor_service_.checkSyntax( + current_logic_id_); + reportLogicSyntaxCheck(result, tr("语法检查")); +} + +void MainWindow::runDoubleCoilCheck() +{ + const LogicSyntaxCheckResult result = + logic_editor_service_.checkDoubleCoils(current_logic_id_); + reportLogicSyntaxCheck(result, tr("双线圈检查")); +} + +void MainWindow::reportLogicSyntaxCheck( + const LogicSyntaxCheckResult &result, const QString &action) +{ + if (result.changed) + { + logic_editor_widget_->reloadLogic(); + refreshProjectUi(); + } + QString message = action + QStringLiteral(": ") + fromUtf8(result.message); + if (result.removedWireCells > 0U + || result.removedVerticalConnections > 0U) + { + message += tr(";已规整 %1 格横线、%2 段竖线") + .arg(result.removedWireCells) + .arg(result.removedVerticalConnections); + } + appendOutputMessage(message, result.location); + ui_->outputDock->show(); + ui_->outputDock->raise(); + statusBar()->showMessage(message, 6000); + if (!result.valid && result.location.has_value()) + { + focusLogicSyntaxLocation(*result.location); + } + updateEditActions(); +} + +void MainWindow::focusLogicSyntaxLocation( + const LogicSyntaxLocation &location) +{ + if (logic_editor_service_.findRung( + location.logicId, location.rungId) == nullptr) + { + return; + } + current_logic_id_ = location.logicId; + ui_->editorTabWidget->setCurrentWidget(ui_->logicEditorTab); + refreshProjectUi(); + logic_editor_widget_->focusSyntaxLocation( + location.rungId, location.column); +} + void MainWindow::deleteSelectedLogicObject() { const LogicEditorResult result = logic_editor_widget_->deleteSelected(); @@ -1902,6 +2057,16 @@ void MainWindow::loadProject() void MainWindow::exportRuntimeProgram() { + const LogicSyntaxCheckResult syntax = + logic_editor_service_.checkEnabledSyntax(); + if (syntax.changed || !syntax.completed || !syntax.valid) + { + reportLogicSyntaxCheck(syntax, tr("导出前语法检查")); + } + if (!syntax.completed || !syntax.valid) + { + return; + } std::string validation_error; if (!project_service_.project().validateForRunning( project_service_.projectLimits(), &validation_error)) @@ -2106,7 +2271,9 @@ void MainWindow::showProjectResult( } } -void MainWindow::appendOutputMessage(const QString &message) +void MainWindow::appendOutputMessage( + const QString &message, + const std::optional &location) { const int maximum = application_settings_result_ .settings.projectLimits.maximumOutputMessages; @@ -2114,7 +2281,19 @@ void MainWindow::appendOutputMessage(const QString &message) { delete ui_->outputList->takeItem(0); } - ui_->outputList->addItem(message); + auto *item = new QListWidgetItem(message, ui_->outputList); + if (location.has_value()) + { + item->setData( + kSyntaxLogicIdRole, fromUtf8(location->logicId)); + item->setData( + kSyntaxRungIdRole, fromUtf8(location->rungId)); + item->setData(kSyntaxColumnRole, location->column); + item->setToolTip(tr("双击定位到网络 %1,第 %2 行第 %3 列") + .arg(location->network) + .arg(location->row) + .arg(location->column)); + } ui_->outputList->scrollToBottom(); } @@ -2147,6 +2326,19 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) return false; } } + const bool runtime_request = requested_mode == ApplicationMode::OfflineRunning + || requested_mode == ApplicationMode::OnlineRunning; + if (runtime_request + && (result.succeeded + || result.error == ModeTransitionError::ProjectNotReady)) + { + const LogicSyntaxCheckResult &syntax = + runtime_mode_service_.lastSyntaxCheck(); + if (syntax.completed && (syntax.changed || !syntax.valid)) + { + reportLogicSyntaxCheck(syntax, tr("运行前语法检查")); + } + } if (!result.succeeded) { restoreCurrentModeAction(); @@ -2155,6 +2347,10 @@ bool MainWindow::requestMode(ApplicationMode requested_mode) { message += tr(";当前状态:%1").arg(plcStatusText(runtime_mode_service_)); } + if (!result.detail.empty()) + { + message += QStringLiteral(": ") + fromUtf8(result.detail); + } if (result.error == ModeTransitionError::SimulationStartFailed) { const LogicScanResult &error = requested_mode @@ -2211,6 +2407,13 @@ void MainWindow::updateModeUi(const QString &message) hmi_editor_widget_->setRuntimeActive( policy.usesVirtualRegisters || policy.usesPlcRegisters); logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); + if (!policy.allowsProjectEditing) + { + const QSignalBlocker draw_blocker(ui_->mouseDrawWireAction); + const QSignalBlocker erase_blocker(ui_->mouseEraseWireAction); + ui_->mouseDrawWireAction->setChecked(false); + ui_->mouseEraseWireAction->setChecked(false); + } if (mode == ApplicationMode::Editing) { alarm_service_.reset(); diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index ea3486f..8ff4c12 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -13,10 +13,12 @@ #include "domain/runtime_state.h" #include "services/plc_communication_gateway.h" #include "services/application_settings.h" +#include "services/logic_editor_service.h" #include #include +#include #include #include @@ -193,8 +195,24 @@ private: void configureAndSetLogicOutput(const LogicNodeConfig &config); /** 新增一个梯形图网络 */ void addLogicRung(); + /** 在当前网络上方插入一行 */ + void insertLogicRungAbove(); + /** 在当前网络下方插入一行 */ + void insertLogicRungBelow(); + /** 删除当前选中的梯形图行 */ + void deleteLogicRung(); /** 编辑当前网络的注释 */ void editSelectedRungComment(); + /** 规整并检查当前梯形图 */ + void runLogicSyntaxCheck(); + /** 单独检查当前梯形图中的重复线圈输出 */ + void runDoubleCoilCheck(); + /** 将语法检查结果写入输出栏并定位错误 */ + void reportLogicSyntaxCheck( + const LogicSyntaxCheckResult &result, + const QString &action); + /** 切换到并聚焦语法错误所在的逻辑位置 */ + void focusLogicSyntaxLocation(const LogicSyntaxLocation &location); /** 删除当前选中的逻辑节点或网络 */ void deleteSelectedLogicObject(); /** 打开 PLC 连接 */ @@ -216,7 +234,9 @@ private: /** 在状态栏和输出面板显示工程操作结果 */ void showProjectResult(const QString &action, const QString &message, bool succeeded); /** 向输出面板追加一条消息 */ - void appendOutputMessage(const QString &message); + void appendOutputMessage( + const QString &message, + const std::optional &location = std::nullopt); /** * @brief 请求服务层切换模式并同步界面状态 @@ -321,23 +341,13 @@ private: std::vector controls; int pasteCount = 0; }; - struct LogicNodesClipboardData + struct LogicClipboardData { - std::vector nodes; - }; - struct LogicOutputClipboardData - { - LogicNode output; - }; - struct LogicRungClipboardData - { - LadderRung rung; + LogicClipboardFragment fragment; }; using EditorClipboard = std::variant< std::monostate, HmiClipboardData, - LogicNodesClipboardData, - LogicOutputClipboardData, - LogicRungClipboardData>; + LogicClipboardData>; EditorClipboard editor_clipboard_; }; diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 34c2c06..460a29d 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -261,14 +261,20 @@ + + + - - 运行(&R) - + + 运行(&R) + + + + @@ -344,6 +350,12 @@ true + + + + + + @@ -353,6 +365,8 @@ + + @@ -1139,12 +1153,47 @@ 在梯形图末尾新增网络 + + 上方插入行 + 在当前选中行的上方插入一条空白行,并保持竖线连接 + + + 下方插入行 + 在当前选中行的下方插入一条空白行,并保持竖线连接 + + + 删除行 + 删除当前选中行;上下同列竖线会自动合并 + Shift+Delete + + + + true + + + 鼠标画线 + + + 拖动鼠标画横线或竖线;横线按网格补齐,竖线按相邻行分段连接 + + + + + true + + + 鼠标删线 + + + 拖动鼠标删除经过的横线格;点击竖线只删除命中的连接段 + + 横线 - 在所选条件或横线后插入横线;没有选择时追加到当前网络 + 在当前条件格插入横线并自动右移;空逻辑会先创建首行 F11 @@ -1155,7 +1204,7 @@ 竖线 - 为所选连续逻辑范围建立横线旁路和竖线连接 + 在当前行与下一行的选中列边界建立一段竖线连接 F12 @@ -1177,7 +1226,7 @@ 删除竖线 - 删除画布中选中的竖线及其对应并联支路 + 删除画布中选中的竖线连接,让网络在此处拆分 Shift+F12 @@ -1196,7 +1245,7 @@ 常开 - 在选中的空网格或横线处添加常开触点;选中条件时在其后插入 + 在当前条件格添加常开触点并自动右移;空逻辑会先创建首行 @@ -1204,7 +1253,7 @@ 常闭 - 在选中的空网格或横线处添加常闭触点;选中条件时在其后插入 + 在当前条件格添加常闭触点并自动右移;空逻辑会先创建首行 @@ -1277,10 +1326,29 @@ - 删除节点/网络 + 删除所选对象 + + + 删除选中的逻辑节点、横线格或竖线;整行请使用删除行 + + + + + 语法检查 + + + 规整当前梯形图并检查输出路径 + + + Ctrl+G + + + + + 双线圈检查 - 删除选中的逻辑节点或网络 + 检查当前梯形图中的重复 M 线圈输出 diff --git a/app/src/ui/runtime_monitor_widget.cpp b/app/src/ui/runtime_monitor_widget.cpp index 1b1c96a..de7b591 100644 --- a/app/src/ui/runtime_monitor_widget.cpp +++ b/app/src/ui/runtime_monitor_widget.cpp @@ -231,8 +231,7 @@ void RuntimeMonitorWidget::setLogicTrace( latest_trace_ = trace; latest_fault_node_id_ = fault_node_id; has_logic_trace_ = true; - logic_view_->setRuntimeTrace( - trace.forLogic(selected_logic_id_), fault_node_id); + logic_view_->setRuntimeTrace(trace, fault_node_id); } FreeMonitorWidget *RuntimeMonitorWidget::freeMonitorWidget() const @@ -265,7 +264,6 @@ void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id) logic_view_->reloadLogic(); if (has_logic_trace_) { - logic_view_->setRuntimeTrace( - latest_trace_.forLogic(logic_id), latest_fault_node_id_); + logic_view_->setRuntimeTrace(latest_trace_, latest_fault_node_id_); } } diff --git a/app/src/ui/runtime_panel_controller.cpp b/app/src/ui/runtime_panel_controller.cpp index 41940b1..47d524b 100644 --- a/app/src/ui/runtime_panel_controller.cpp +++ b/app/src/ui/runtime_panel_controller.cpp @@ -313,8 +313,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) hmi_navigation_service_.currentPageId(), logic_id); } logic_editor_widget_.setRuntimeTrace( - runtime_mode_service_.offlineSimulationService() - .traceSnapshot().forLogic(logic_id), + runtime_mode_service_.offlineSimulationService().traceSnapshot(), error.nodeId); if (runtime_monitor_widget_ != nullptr) { @@ -393,7 +392,7 @@ void RuntimePanelController::handleScanCompleted() const LogicTraceSnapshot &trace = offline_running ? runtime_mode_service_.offlineSimulationService().traceSnapshot() : runtime_mode_service_.onlineLogicMonitorService().traceSnapshot(); - logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id)); + logic_editor_widget_.setRuntimeTrace(trace); if (runtime_monitor_widget_ != nullptr) { runtime_monitor_widget_->setLogicTrace(trace); diff --git a/app/src/ui/toolbar_icon_factory.cpp b/app/src/ui/toolbar_icon_factory.cpp index 6b74361..693b3c1 100644 --- a/app/src/ui/toolbar_icon_factory.cpp +++ b/app/src/ui/toolbar_icon_factory.cpp @@ -351,6 +351,23 @@ QPixmap renderIcon(UiIcon icon, int size) painter.drawLine(QPointF(7, 12), QPointF(17, 12)); break; } + case UiIcon::MouseDrawWire: + { + painter.drawLine(QPointF(3, 16), QPointF(18, 16)); + painter.drawLine(QPointF(18, 16), QPointF(18, 6)); + painter.setPen(iconPen(kAccent, 2.0)); + painter.drawLine(QPointF(6, 12), QPointF(6, 20)); + painter.drawLine(QPointF(2, 16), QPointF(10, 16)); + break; + } + case UiIcon::MouseEraseWire: + { + painter.drawLine(QPointF(3, 16), QPointF(21, 16)); + painter.drawLine(QPointF(15, 16), QPointF(15, 6)); + painter.setPen(iconPen(kDanger, 2.2)); + painter.drawLine(QPointF(6, 20), QPointF(20, 5)); + break; + } case UiIcon::HorizontalWire: { painter.drawLine(QPointF(3, 12), QPointF(21, 12)); @@ -460,6 +477,16 @@ QPixmap renderIcon(UiIcon icon, int size) painter.drawEllipse(QPointF(18, 12), 1.7, 1.7); break; } + case UiIcon::SyntaxCheck: + { + painter.drawLine(QPointF(3, 3), QPointF(3, 21)); + painter.drawLine(QPointF(3, 8), QPointF(13, 8)); + painter.drawLine(QPointF(3, 16), QPointF(10, 16)); + painter.setPen(iconPen(kAccent, 2.0)); + painter.drawLine(QPointF(11, 15), QPointF(15, 19)); + painter.drawLine(QPointF(15, 19), QPointF(22, 9)); + break; + } case UiIcon::HmiPage: { painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5); diff --git a/app/src/ui/toolbar_icon_factory.h b/app/src/ui/toolbar_icon_factory.h index e1fcf39..2bbe05f 100644 --- a/app/src/ui/toolbar_icon_factory.h +++ b/app/src/ui/toolbar_icon_factory.h @@ -36,6 +36,8 @@ enum class UiIcon AlarmSettings, // 报警配置 Delete, // 删除 AddRung, // 新增网络 + MouseDrawWire, // 鼠标画线模式 + MouseEraseWire, // 鼠标删线模式 HorizontalWire, // 横线 VerticalWire, // 竖线 ParallelBranch, // 并联支路 @@ -51,6 +53,7 @@ enum class UiIcon Subtract, // SUB 指令 Compare, // 比较指令 Comment, // 注释 + SyntaxCheck, // 梯形图语法检查与规整 More, // 更多操作 HmiPage, // HMI 页面 Logic, // 控制逻辑 diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index d2de684..db1e253 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -13,7 +13,6 @@ #include #include #include -#include #include #include #include @@ -24,30 +23,6 @@ namespace { using TestSupport::require; -int expressionColumns(const ConditionExpression &expression) -{ - if (expression.kind == ConditionExpressionKind::Node) - { - return 1; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - return expression.wire->columnSpan; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return expression.gap->columnSpan; - } - int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; - for (const ConditionExpression &child : expression.children) - { - const int child_columns = expressionColumns(child); - columns = expression.kind == ConditionExpressionKind::Series - ? columns + child_columns : std::max(columns, child_columns); - } - return columns; -} - void testRegisterAddressBoundaries() { // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 @@ -336,18 +311,20 @@ Project makeValidProject() LadderRung rung; rung.id = "rung-1"; rung.name = "Network 1"; - ConditionExpression condition; - condition.id = "start-series"; - condition.kind = ConditionExpressionKind::Series; - condition.children = { - ConditionExpression::fromNode(contact), - ConditionExpression::fromWire("start-wire", 9)}; - rung.condition = std::move(condition); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + "start-cell-" + std::to_string(column), + column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, + column == 0 ? std::optional{contact} : std::nullopt}); + } rung.output = coil; logic.rungs.push_back(rung); Project project; - project.metadata = {"sample-project", "Sample project", "1.0"}; + project.metadata = {"sample-project", "Sample project", "2.0"}; project.hmiPages.push_back(page); project.initialHmiPageId = page.id; project.controlLogics.push_back(logic); @@ -402,8 +379,10 @@ void testMultiPageAndLogicDomainRules() disabled_draft.id = "draft-logic"; disabled_draft.name = "Draft logic"; disabled_draft.enabled = false; - disabled_draft.rungs.push_back( - {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); + LadderRung draft_rung; + draft_rung.id = "rung-1"; + draft_rung.name = "Draft network"; + disabled_draft.rungs.push_back(std::move(draft_rung)); project.controlLogics.push_back(disabled_draft); require(project.validateForRunning(), "a disabled draft logic must not block offline running"); @@ -521,60 +500,10 @@ void testQuantityBoundaries() project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; require(!project.validate(), "an HMI page height of 199 must be rejected"); - ConditionExpression leaf = ConditionExpression::fromNode({ - "depth-node-0", - ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}}, - true}); - std::function makeNested = - [&makeNested](int depth, int *next_address) - { - if (depth == 1) - { - const int address = (*next_address)++; - return ConditionExpression::fromNode({ - "depth-node-" + std::to_string(address), - ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, - true}); - } - const int address = (*next_address)++; - ConditionExpression nested = makeNested(depth - 1, next_address); - ConditionExpression sibling = ConditionExpression::fromNode({ - "depth-node-" + std::to_string(address), - ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, - true}); - ConditionExpression expression; - expression.id = "depth-expression-" + std::to_string(address); - expression.kind = depth % 2 == 0 - ? ConditionExpressionKind::Parallel - : ConditionExpressionKind::Series; - if (expression.kind == ConditionExpressionKind::Parallel - && expressionColumns(nested) > 1) - { - ConditionExpression padded_sibling; - padded_sibling.id = "depth-padding-" + std::to_string(address); - padded_sibling.kind = ConditionExpressionKind::Series; - padded_sibling.children = { - std::move(sibling), - ConditionExpression::fromWire( - "depth-wire-" + std::to_string(address), - expressionColumns(nested) - 1)}; - sibling = std::move(padded_sibling); - } - expression.children = {std::move(nested), std::move(sibling)}; - return expression; - }; - int next_address = 1; - ConditionExpression maximum_depth = makeNested( - static_cast(ProjectLimits::kMaximumExpressionDepth), &next_address); - require(maximum_depth.validate(), - "an expression depth at the configured limit must be accepted"); - ConditionExpression excessive_depth = makeNested( - static_cast(ProjectLimits::kMaximumExpressionDepth) + 1, - &next_address); - require(!excessive_depth.validate(), - "an expression depth above the configured limit must be rejected"); - - (void)leaf; + project = makeValidProject(); + project.controlLogics.front().rungs.front().cells.pop_back(); + require(!project.validate(), + "a ladder row with fewer than ten cells must be rejected"); } void testLogicNodeConfigurationBoundaries() @@ -674,151 +603,106 @@ void testDataInstructionBoundaries() void testLadderLogicBoundaries() { - LogicNode stop; - stop.id = "stop"; - stop.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - ContactMode::NormallyClosed}; - - LogicNode start; - start.id = "start"; - start.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 0}, - ContactMode::NormallyOpen}; - - LogicNode run_contact; - run_contact.id = "run-contact"; - run_contact.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - ContactMode::NormallyOpen}; - - LogicNode coil; - coil.id = "run-coil"; - coil.config = CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - CoilMode::Normal}; - ControlLogic logic; - logic.id = "hold-logic"; - logic.name = "Hold logic"; - ConditionExpression start_parallel; - start_parallel.id = "parallel-start"; - start_parallel.kind = ConditionExpressionKind::Parallel; - start_parallel.children = { - ConditionExpression::fromNode(start), - ConditionExpression::fromNode(run_contact)}; - ConditionExpression root; - root.id = "series-root"; - root.kind = ConditionExpressionKind::Series; - root.children = { - ConditionExpression::fromNode(stop), - start_parallel, - ConditionExpression::fromWire("hold-output-wire", 8)}; - LadderRung rung; - rung.id = "rung-1"; - rung.name = "Self hold"; - rung.condition = root; - rung.output = coil; - logic.rungs.push_back(rung); - require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid"); - - ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2); - require(wire.validate() && wire.validateForRunning() - && wire.wire->columnSpan == 2, - "a configured horizontal wire must be a valid runnable expression leaf"); - ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0); - require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected"); - invalid_wire = ConditionExpression::fromWire( - "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); - require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); - - ConditionExpression gap = ConditionExpression::fromGap("gap-1", 1); - require(gap.validate() && !gap.validateForRunning(), - "a gap must be a valid editing draft but must block runtime validation"); - - ConditionExpression maximum_columns; - maximum_columns.id = "maximum-columns"; - maximum_columns.kind = ConditionExpressionKind::Series; - for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) + logic.id = "grid-logic"; + logic.name = "Grid logic"; + LadderRung upper; + upper.id = "rung-1"; + upper.name = "Row 1"; + LadderRung lower; + lower.id = "rung-2"; + lower.name = "Row 2"; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - LogicNode node; - node.id = "column-" + std::to_string(column + 1); - node.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, column}, - ContactMode::NormallyOpen}; - maximum_columns.children.push_back( - ConditionExpression::fromNode(std::move(node))); + upper.cells.push_back({ + "upper-cell-" + std::to_string(column), + LadderCellKind::Wire, + std::nullopt}); + lower.cells.push_back({ + "lower-cell-" + std::to_string(column), + LadderCellKind::Gap, + std::nullopt}); } - require(maximum_columns.validate(), - "ten condition columns must be accepted"); - LogicNode extra_column; - extra_column.id = "column-11"; - extra_column.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 10}, - ContactMode::NormallyOpen}; - maximum_columns.children.push_back( - ConditionExpression::fromNode(std::move(extra_column))); - require(!maximum_columns.validate(), - "an eleventh condition column must be rejected"); - - ConditionExpression wired_series; - wired_series.id = "wired-series"; - wired_series.kind = ConditionExpressionKind::Series; - wired_series.children = { - ConditionExpression::fromNode(stop), - ConditionExpression::fromWire("wire-series"), - start_parallel}; - require(wired_series.validateForRunning(), - "a wire must preserve a valid structured series expression"); - - logic.rungs.front().condition->children.front() = - ConditionExpression::fromNode(coil); - require(!logic.validate(), "a ladder condition expression must reject coils"); - logic.rungs.front().condition = root; - logic.rungs.front().output = start; - require(!logic.validate(), "a ladder output must be a coil"); + upper.cells[0].kind = LadderCellKind::Node; + upper.cells[0].node = LogicNode{ + "start", + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 0}, + ContactMode::NormallyOpen}, + true}; + upper.output = LogicNode{ + "run-coil", + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal}, + true}; + logic.rungs = {upper, lower}; + logic.verticalConnections = { + {"vertical-left", "rung-1", "rung-2", 0}, + {"vertical-right", "rung-1", "rung-2", 1}}; + require(logic.validate() && logic.validateForRunning(), + "a ten-cell grid with adjacent vertical edges must be valid"); + + logic.rungs.front().cells[5].kind = LadderCellKind::Gap; + std::string connectivity_error; + require( + logic.validate() && !logic.validateForRunning(&connectivity_error) + && connectivity_error.find("第 1 行") != std::string::npos + && connectivity_error.find("第 6 列") != std::string::npos, + "a disconnected output must report its visual row and break column"); + + logic.rungs.front() = upper; + logic.rungs.front().cells[5].kind = LadderCellKind::Gap; + for (LadderCell &cell : logic.rungs.back().cells) + { + cell.kind = LadderCellKind::Wire; + cell.node.reset(); + } + logic.verticalConnections = { + {"vertical-left", "rung-1", "rung-2", 0}, + {"vertical-bypass", "rung-1", "rung-2", 6}}; + require( + logic.validateForRunning(), + "a vertical branch that bypasses a gap must keep the output reachable"); + + logic.rungs = {upper, lower}; + logic.verticalConnections = { + {"vertical-left", "rung-1", "rung-2", 0}, + {"vertical-right", "rung-1", "rung-2", 1}}; + logic.rungs.front().cells.front().node = LogicNode{ + "invalid-coil", + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 2}, CoilMode::Normal}, + true}; + require(!logic.validate(), "a condition cell must reject output nodes"); + + logic.rungs.front() = upper; + logic.rungs.front().output = LogicNode{ + "invalid-contact", + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 2}, ContactMode::NormallyOpen}, + true}; + require(!logic.validate(), "the output slot must reject condition nodes"); + logic.rungs.front() = upper; logic.rungs.front().output.reset(); - require(logic.validate(), "incomplete ladder may remain in an editable draft"); - require(!logic.validateForRunning(), - "conditions without an output must block runtime validation"); - - LadderRung empty_rung{ - "rung-empty", "Empty network", {}, std::nullopt, std::nullopt}; - require(empty_rung.validate(), "an empty editing network must be valid"); - - empty_rung.output = coil; - require(!empty_rung.validate(), - "an output without an explicit ten-column condition path must be rejected"); - empty_rung.condition = ConditionExpression::fromWire("unconditional-wire", 10); - require(empty_rung.validate() && empty_rung.validateForRunning(), - "a ten-column wire path must represent a runnable unconditional rung"); - empty_rung.condition = ConditionExpression::fromGap("unconditional-gap", 10); - require(empty_rung.validate() && !empty_rung.validateForRunning(), - "a full-width gap draft must remain saved but disconnected"); - - logic.rungs.front().output = coil; - logic.rungs.front().condition = root; - logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id; - require(!logic.validate(), "logic node ids must be unique"); - - ConditionExpression nested_parallel; - nested_parallel.id = "parallel-nested"; - nested_parallel.kind = ConditionExpressionKind::Parallel; - nested_parallel.children = { - ConditionExpression::fromNode(start), - root}; - require(!nested_parallel.validate(), - "parallel branches with unequal explicit widths must be rejected"); - ConditionExpression padded_start; - padded_start.id = "padded-start"; - padded_start.kind = ConditionExpressionKind::Series; - padded_start.children = { - ConditionExpression::fromNode(start), - ConditionExpression::fromWire("nested-parallel-wire", 9)}; - nested_parallel.children.front() = std::move(padded_start); - require(nested_parallel.validate(), - "parallel branches padded with explicit wires must be valid"); + require(logic.validate() && logic.validateForRunning(), + "a row without an output may act as a connected branch"); + + logic.rungs.front() = upper; + logic.rungs.front().cells[1].id = logic.rungs.front().cells[0].id; + require(!logic.validate(), "cell ids must be unique within a logic"); + + logic.rungs.front() = upper; + logic.verticalConnections.front().lowerRungId = "missing-rung"; + require(!logic.validate(), "vertical edges must reference adjacent rows"); + + logic.verticalConnections = { + {"vertical-left", "rung-1", "rung-2", 0}, + {"vertical-copy", "rung-1", "rung-2", 0}}; + require(!logic.validate(), + "one row boundary must not contain duplicate vertical edges"); } void testModelsValidateBindingsAndIdentifiers() diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index f3fcb40..d3a1529 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -1,1759 +1,1407 @@ -#include "domain/project_storage.h" #include "domain/project_limits.h" -#include "services/logic_editor_service.h" #include "services/logic_command_service.h" -#include "services/editor_history.h" +#include "services/logic_editor_service.h" #include "services/project_service.h" #include "support/test_support.h" #include #include #include +#include +#include namespace { -using TestProjectStorage = TestSupport::InMemoryProjectStorage; using TestSupport::require; -ContactNodeConfig contact(int address) +ContactNodeConfig contact( + int address, ContactMode mode = ContactMode::NormallyOpen) { - return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; + return {RegisterAddress{RegisterArea::M, address}, mode}; } -std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id) +struct Fixture { - const LogicEditorResult result = service.addRung(logic_id); - require(result.succeeded, "test fixture must create an empty network"); - return result.id; -} + TestSupport::InMemoryProjectStorage storage; + ProjectService projects{storage}; + LogicEditorService editor{projects}; + std::string logicId; -int conditionColumns(const ConditionExpression &expression) -{ - if (expression.kind == ConditionExpressionKind::Node) - { - return 1; - } - if (expression.kind == ConditionExpressionKind::Wire) + Fixture() { - return expression.wire->columnSpan; + const LogicEditorResult result = editor.ensureDefaultLogic(); + require(result.succeeded, "fixture must create the default logic"); + logicId = result.id; } - if (expression.kind == ConditionExpressionKind::Gap) - { - return expression.gap->columnSpan; - } - int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; - for (const ConditionExpression &child : expression.children) - { - const int child_columns = conditionColumns(child); - columns = expression.kind == ConditionExpressionKind::Series - ? columns + child_columns : std::max(columns, child_columns); - } - return columns; -} -int wireColumns(const ConditionExpression &expression) -{ - if (expression.kind == ConditionExpressionKind::Node) - { - return 0; - } - if (expression.kind == ConditionExpressionKind::Wire) - { - return expression.wire->columnSpan; - } - if (expression.kind == ConditionExpressionKind::Gap) - { - return 0; - } - int columns = 0; - for (const ConditionExpression &child : expression.children) + std::string addRung() { - columns += wireColumns(child); + const LogicEditorResult result = editor.addRung(logicId); + require(result.succeeded, "fixture must create a ladder row"); + return result.id; } - return columns; -} +}; -int gapColumns(const ConditionExpression &expression) +const VerticalConnection *connectionAt( + const ControlLogic &logic, + const std::string &upper, + const std::string &lower, + int boundary) { - if (expression.kind == ConditionExpressionKind::Gap) - { - return expression.gap->columnSpan; - } - if (expression.kind == ConditionExpressionKind::Node - || expression.kind == ConditionExpressionKind::Wire) - { - return 0; - } - int columns = 0; - for (const ConditionExpression &child : expression.children) - { - columns += gapColumns(child); - } - return columns; + const auto found = std::find_if( + logic.verticalConnections.cbegin(), + logic.verticalConnections.cend(), + [&upper, &lower, boundary](const VerticalConnection &connection) + { + return connection.upperRungId == upper + && connection.lowerRungId == lower + && connection.columnBoundary == boundary; + }); + return found == logic.verticalConnections.cend() ? nullptr : &*found; } -int conditionNodes(const ConditionExpression &expression) +void requireEmptyGrid(const LadderRung &rung) { - if (expression.kind == ConditionExpressionKind::Node) - { - return 1; - } - if (expression.kind == ConditionExpressionKind::Wire) + require( + rung.cells.size() + == static_cast( + ProjectLimits::kMaximumConditionColumns), + "every visual row must have exactly ten persisted cells"); + for (const LadderCell &cell : rung.cells) { - return 0; + require( + !cell.id.empty() && cell.kind == LadderCellKind::Gap + && !cell.node.has_value(), + "a new row must contain stable empty cells"); } - if (expression.kind == ConditionExpressionKind::Gap) - { - return 0; - } - int count = 0; - for (const ConditionExpression &child : expression.children) - { - count += conditionNodes(child); - } - return count; } -const ConditionExpression *firstExpressionOfKind( - const ConditionExpression &expression, ConditionExpressionKind kind) +void testContinuousGridAndIndependentHorizontalWires() { - if (expression.kind == kind) - { - return &expression; - } - for (const ConditionExpression &child : expression.children) + Fixture fixture; + const std::string rung_id = fixture.addRung(); + requireEmptyGrid(*fixture.editor.findRung(fixture.logicId, rung_id)); + + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, rung_id, 2, 5, true).succeeded, + "drawing a horizontal range must succeed"); + const LadderRung *rung = fixture.editor.findRung( + fixture.logicId, rung_id); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - if (const ConditionExpression *found = firstExpressionOfKind(child, kind)) - { - return found; - } + const LadderCellKind expected = column >= 2 && column <= 5 + ? LadderCellKind::Wire : LadderCellKind::Gap; + require( + rung->cells[static_cast(column)].kind == expected, + "horizontal wires must be persisted independently per cell"); } - return nullptr; -} -void testLogicCommandParsing() -{ - const LogicCommandParseResult load = LogicCommandService::parse("ldi m4000"); - require(load.succeeded - && load.command.opcode == LogicCommandOpcode::LoadInverse - && std::get(load.command.config).address.index() == 4000 - && std::get(load.command.config).mode - == ContactMode::NormallyClosed, - "LDI must parse case-insensitively into a configured contact"); - - const LogicCommandParseResult add = LogicCommandService::parse( - "ADD D0 D1 D4000"); - require(add.succeeded - && add.command.opcode == LogicCommandOpcode::Add - && std::get(add.command.config) - .destination.index() == 4000, - "ADD must parse three D operands through the shared address rules"); - const LogicCommandParseResult compare = LogicCommandService::parse( - "LD<= D12 -3"); - require(compare.succeeded - && compare.command.opcode - == LogicCommandOpcode::CompareLessThanOrEqual - && std::get(compare.command.config).address.index() == 12 - && std::get(compare.command.config).value == -3, - "LD comparison commands must parse a D address and signed constant"); - const LogicCommandParseResult move_constant = LogicCommandService::parse( - "MOV -7 D20"); - require(move_constant.succeeded - && std::get(move_constant.command.config).source.kind - == WordOperandKind::Constant - && std::get(move_constant.command.config).source.constant == -7, - "MOV must preserve constant sources when an existing output is reopened"); - const LogicCommandParseResult add_constant = LogicCommandService::parse( - "ADD D0 1 D2"); - require(add_constant.succeeded - && std::get(add_constant.command.config).right.kind - == WordOperandKind::Constant, - "ADD must accept constant operands used by the instruction dialog"); - require(!LogicCommandService::parse("LD D0").succeeded, - "contact commands must reject D addresses"); - require(!LogicCommandService::parse("ADD D0 D1 D4001").succeeded, - "data commands must reject out-of-range D addresses"); - require(!LogicCommandService::parse("TON T0").succeeded, - "unsupported commands must be rejected explicitly"); - require(!LogicCommandService::parse("OUT M0 M1").succeeded, - "commands with the wrong operand count must be rejected"); - - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService editor(project_service); - LogicCommandService commands(editor); - const std::string logic_id = editor.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(editor, logic_id); - LogicCommandRequest and_without_load; - and_without_load.logicId = logic_id; - and_without_load.text = "AND M0"; - and_without_load.target = { - LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; - require(!commands.execute(and_without_load).succeeded, - "AND without a preceding LD must be rejected"); + const LogicEditorResult node = fixture.editor.setConditionAtColumn( + fixture.logicId, rung_id, 3, contact(3), true); + require(node.succeeded, "a wire cell must accept a condition node"); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, rung_id, 2, 5, false).succeeded, + "erasing a horizontal range must succeed"); + rung = fixture.editor.findRung(fixture.logicId, rung_id); + require( + rung->cells[3].kind == LadderCellKind::Node + && rung->cells[3].node->id == node.id, + "line erasing must not delete a condition node"); + require( + rung->cells[2].kind == LadderCellKind::Gap + && rung->cells[4].kind == LadderCellKind::Gap + && rung->cells[5].kind == LadderCellKind::Gap, + "line erasing must clear each selected wire cell"); } -void testContinuousLogicCommands() +void testIndependentVerticalConnectionsAndNetworkSplit() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService editor(project_service); - LogicCommandService commands(editor); - const std::string logic_id = editor.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(editor, logic_id); - - LogicCommandRequest request; - request.logicId = logic_id; - request.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; - request.text = "LD M1"; - const LogicCommandResult load = commands.execute(request); - require(load.succeeded && load.rungId == rung_id, - "LD on an empty cell must create the first condition in that network"); - - request.continuing = true; - request.currentRungId = load.rungId; - request.text = "AND M2"; - const LogicCommandResult and_result = commands.execute(request); - require(and_result.succeeded, - "AND must append to the current command-input network"); - request.text = "OR M3"; - const LogicCommandResult or_result = commands.execute(request); - require(or_result.succeeded, - "OR must parallel the complete accumulated condition: " - + or_result.message); - - const LadderRung *rung = editor.findRung(logic_id, rung_id); - const ConditionExpression *parallel = firstExpressionOfKind( - *rung->condition, ConditionExpressionKind::Parallel); - require(parallel != nullptr && parallel->children.size() == 2U - && parallel->children.front().kind == ConditionExpressionKind::Series - && conditionNodes(parallel->children.front()) == 2 - && conditionNodes(parallel->children.back()) == 1, - "LD M1, AND M2, OR M3 must form (M1 AND M2) OR M3"); - - request.text = "OUT M4"; - const LogicCommandResult output = commands.execute(request); - rung = editor.findRung(logic_id, rung_id); - require(output.succeeded && rung != nullptr && rung->validateForRunning(), - "command-created contacts and output must be configured and runnable"); - - request.text = "LDI M5"; - const LogicCommandResult next_load = commands.execute(request); - require(next_load.succeeded && next_load.rungId != rung_id - && editor.findLogic(logic_id)->rungs.size() == 2U, - "a consecutive LD after an output must create the next network"); + Fixture fixture; + const std::string first = fixture.addRung(); + const std::string second = fixture.addRung(); + const std::string third = fixture.addRung(); + + require( + fixture.editor.setVerticalConnectionRange( + fixture.logicId, first, third, 4, true).succeeded, + "a vertical gesture must create all adjacent segments"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require( + logic->verticalConnections.size() == 2U + && connectionAt(*logic, first, second, 4) != nullptr + && connectionAt(*logic, second, third, 4) != nullptr, + "a long vertical line must be represented by independent segments"); + + const std::string first_segment = + connectionAt(*logic, first, second, 4)->id; + require( + fixture.editor.removeVerticalConnections( + fixture.logicId, {first_segment}).succeeded, + "an individual vertical segment must be removable"); + logic = fixture.editor.findLogic(fixture.logicId); + require( + connectionAt(*logic, first, second, 4) == nullptr + && connectionAt(*logic, second, third, 4) != nullptr, + "deleting one segment must split the connected network"); + + require( + !fixture.editor.setVerticalConnection( + fixture.logicId, first, third, 4, true).succeeded, + "the model must reject vertical edges between non-adjacent rows"); } -void testExistingNodeCommandReplacement() +void testInsertRowSplitsVerticalEdges() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService editor(project_service); - LogicCommandService commands(editor); - const std::string logic_id = editor.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(editor, logic_id); - const LogicEditorResult contact_result = editor.appendCondition( - logic_id, rung_id, contact(10), true); - require(contact_result.succeeded, - "existing-node command fixture must create a contact"); - - LogicCommandRequest replace_contact; - replace_contact.logicId = logic_id; - replace_contact.text = "LDI M11"; - replace_contact.target = { - LogicCommandTargetKind::ExistingNode, - rung_id, - contact_result.id, - 0}; - const LogicCommandResult replaced_contact = commands.execute(replace_contact); - const LogicNode *contact_node = editor.findNode(logic_id, contact_result.id); - require(replaced_contact.succeeded && contact_node != nullptr - && std::holds_alternative(contact_node->config) - && std::get(contact_node->config).address.index() == 11 - && std::get(contact_node->config).mode - == ContactMode::NormallyClosed, - "an existing contact must accept a load command replacement"); - - const LogicEditorResult output_result = editor.setOutput( - logic_id, - rung_id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, - true); - require(output_result.succeeded, - "existing-node command fixture must create an output"); - LogicCommandRequest replace_output; - replace_output.logicId = logic_id; - replace_output.text = "SET M21"; - replace_output.target = { - LogicCommandTargetKind::ExistingNode, - rung_id, - output_result.id, - 0}; - const LogicCommandResult replaced_output = commands.execute(replace_output); - const LogicNode *output_node = editor.findNode(logic_id, output_result.id); - require(replaced_output.succeeded && output_node != nullptr - && std::holds_alternative(output_node->config) - && std::get(output_node->config).address.index() == 21 - && std::get(output_node->config).mode == CoilMode::Set, - "an existing output must accept an output command replacement"); - - LogicCommandRequest wrong_type = replace_contact; - wrong_type.text = "OUT M30"; - require(!commands.execute(wrong_type).succeeded, - "an existing condition must reject an output command replacement"); + Fixture fixture; + const std::string upper = fixture.addRung(); + const std::string lower = fixture.addRung(); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, upper, lower, 2, true).succeeded, + "fixture must connect the original adjacent rows"); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, upper, lower, 7, true).succeeded, + "fixture must support more than one boundary between two rows"); + + const LogicEditorResult inserted = fixture.editor.insertRung( + fixture.logicId, upper, true); + require(inserted.succeeded, "inserting a row must succeed"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require(logic->rungs.size() == 3U, "the row must be inserted in place"); + requireEmptyGrid(*fixture.editor.findRung(fixture.logicId, inserted.id)); + for (int boundary : {2, 7}) + { + require( + connectionAt(*logic, upper, inserted.id, boundary) != nullptr + && connectionAt(*logic, inserted.id, lower, boundary) + != nullptr + && connectionAt(*logic, upper, lower, boundary) == nullptr, + "insertion must split every crossed vertical edge"); + } } -void testPositionedLogicCommandsAndAtomicFailures() +void testInsertConditionMovesOnlyRelatedVerticalBoundaries() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService editor(project_service); - LogicCommandService commands(editor); - const std::string logic_id = editor.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(editor, logic_id); - const LogicEditorResult first = editor.appendCondition( - logic_id, rung_id, contact(10), true); - const LogicEditorResult second = editor.appendCondition( - logic_id, rung_id, contact(11), true); - - LogicCommandRequest selected_or; - selected_or.logicId = logic_id; - selected_or.text = "ORI M12"; - selected_or.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 2}; - selected_or.parallelNodeIds = {first.id, second.id}; - require(commands.execute(selected_or).succeeded, - "ORI on an existing network must use the selected continuous range"); - - const std::string wire_rung = makeEmptyRung(editor, logic_id); - const LogicEditorResult wire = editor.appendWire(logic_id, wire_rung, 3); - LogicCommandRequest wire_command; - wire_command.logicId = logic_id; - wire_command.text = "LDP M20"; - wire_command.target = { - LogicCommandTargetKind::WireColumn, wire_rung, wire.id, 1}; - const LogicCommandResult inserted = commands.execute(wire_command); - require(inserted.succeeded - && editor.findNode(logic_id, inserted.id)->isConfigured(), - "a command on a wire cell must replace exactly that cell"); - - const std::string gap_rung = makeEmptyRung(editor, logic_id); - const LogicEditorResult gap_wire = editor.appendWire(logic_id, gap_rung, 2); - require(editor.disconnectWireCells(logic_id, gap_rung, {{gap_wire.id, 0}}).succeeded, - "gap command fixture must disconnect one wire cell"); - const ConditionExpression *gap = firstExpressionOfKind( - *editor.findRung(logic_id, gap_rung)->condition, - ConditionExpressionKind::Gap); - LogicCommandRequest gap_command; - gap_command.logicId = logic_id; - gap_command.text = "LD M21"; - gap_command.target = { - LogicCommandTargetKind::GapColumn, gap_rung, gap->id, 0}; - require(commands.execute(gap_command).succeeded - && gapColumns(*editor.findRung(logic_id, gap_rung)->condition) == 0, - "a command on a gap cell must repair that exact gap"); - - editor.clearHistory(); - const std::size_t rung_count = editor.findLogic(logic_id)->rungs.size(); - LogicCommandRequest invalid = gap_command; - invalid.text = "ADD D0 D1 D4001"; - require(!commands.execute(invalid).succeeded - && editor.findLogic(logic_id)->rungs.size() == rung_count - && !editor.canUndo(), - "an invalid command must not change the project or undo history"); - - LogicCommandRequest wrong_position = gap_command; - wrong_position.text = "OUT M30"; - require(!commands.execute(wrong_position).succeeded - && !editor.canUndo(), - "an output command in the condition area must be rejected atomically"); + Fixture fixture; + const std::string first = fixture.addRung(); + const std::string second = fixture.addRung(); + const std::string third = fixture.addRung(); + const std::string fourth = fixture.addRung(); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, first, second, 2, true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, first, second, 10, true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, third, fourth, 5, true).succeeded, + "fixture must create related, output-side, and unrelated edges"); + + const LogicEditorResult inserted = fixture.editor.insertConditionAtColumn( + fixture.logicId, first, 2, contact(12), true); + require(inserted.succeeded, + "inserting a condition into the grid must succeed"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require( + connectionAt(*logic, first, second, 2) == nullptr + && connectionAt(*logic, first, second, 3) != nullptr, + "an edge at the insertion point must follow shifted grid content"); + require( + connectionAt(*logic, first, second, 10) != nullptr, + "the fixed output-side boundary must remain at column ten"); + require( + connectionAt(*logic, third, fourth, 5) != nullptr, + "inserting into one row must not move edges in unrelated rows"); } -void testEmptyLogicCreatesNetworksOnFirstEdit() +void testDeleteRowMergesOnlyContinuousEdges() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - require(service.findLogic(logic_id)->rungs.empty(), - "a default control logic must start without an empty network"); - - const LogicEditorResult first_condition = service.appendCondition( - logic_id, {}, contact(0)); - require(first_condition.succeeded - && service.findLogic(logic_id)->rungs.size() == 1U, - "the first condition must create network 1 atomically"); - const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front(); - require(condition_rung.condition.has_value() - && condition_rung.condition->kind == ConditionExpressionKind::Node, - "the first condition must not create an implicit horizontal wire"); - - require(service.removeRung(logic_id, condition_rung.id).succeeded - && service.findLogic(logic_id)->rungs.empty(), - "the last network must be removable back to an empty logic"); - - const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1); - require(first_wire.succeeded - && service.findLogic(logic_id)->rungs.size() == 1U - && service.findLogic(logic_id)->rungs.front().condition->kind - == ConditionExpressionKind::Wire, - "the first horizontal wire must create a one-cell network"); - - require(service.removeRung( - logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded, - "the wire-only network must be removable"); - const LogicEditorResult first_output = service.setOutput( - logic_id, - {}, - CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}); - require(first_output.succeeded - && service.findLogic(logic_id)->rungs.size() == 1U - && service.findLogic(logic_id)->rungs.front().condition.has_value() - && service.findLogic(logic_id)->rungs.front().condition->kind - == ConditionExpressionKind::Wire - && service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan - == ProjectLimits::kMaximumConditionColumns - && service.findLogic(logic_id)->rungs.front().output.has_value(), - "the first output must create a full-width explicit wire network"); + Fixture fixture; + const std::string upper = fixture.addRung(); + const std::string middle = fixture.addRung(); + const std::string lower = fixture.addRung(); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, upper, middle, 1, true).succeeded, + "fixture must add the upper half of a continuous edge"); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, middle, lower, 1, true).succeeded, + "fixture must add the lower half of a continuous edge"); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, upper, middle, 6, true).succeeded, + "fixture must add an upper-only edge"); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, middle, lower, 8, true).succeeded, + "fixture must add a lower-only edge"); + + require( + fixture.editor.removeRung(fixture.logicId, middle).succeeded, + "deleting the middle row must succeed"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require(logic->rungs.size() == 2U, "the middle row must be removed"); + require( + connectionAt(*logic, upper, lower, 1) != nullptr, + "matching upper and lower segments must merge after row deletion"); + require( + connectionAt(*logic, upper, lower, 6) == nullptr + && connectionAt(*logic, upper, lower, 8) == nullptr, + "a one-sided edge must disappear instead of creating a false bridge"); + require( + logic->rungs[0].name == "行 1" + && logic->rungs[1].name == "行 2", + "row labels must be renumbered after deletion"); } -void testBatchRungDeleteIsAtomic() +void testParallelBranchCreatesConnectedVisualRow() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string first = service.addRung(logic_id).id; - const std::string second = service.addRung(logic_id).id; - const std::string third = service.addRung(logic_id).id; - require(service.findLogic(logic_id)->rungs.size() == 3U, - "the fixture must create three networks"); - - require(service.removeRungs(logic_id, {first, second}).succeeded - && service.findLogic(logic_id)->rungs.size() == 1U - && service.findRung(logic_id, third) != nullptr, - "a selected group of networks must be deleted together"); - require(service.undo().succeeded - && service.findLogic(logic_id)->rungs.size() == 3U, - "undo must restore a batch network deletion as one edit"); - - service.clearHistory(); - require(!service.removeRungs(logic_id, {first, first}).succeeded - && service.findLogic(logic_id)->rungs.size() == 3U - && !service.canUndo(), - "duplicate network selection must fail without mutation or history"); + Fixture fixture; + const std::string source = fixture.addRung(); + const LogicEditorResult first = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 2, contact(1), true); + const LogicEditorResult second = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 3, contact(2), true); + require(first.succeeded && second.succeeded, + "fixture must place adjacent conditions"); + + const LogicEditorResult branch = fixture.editor.addParallelBranch( + fixture.logicId, + source, + {first.id, second.id}, + contact(3), + true); + require(branch.succeeded, "parallel insertion must create a visual row"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require(logic->rungs.size() == 2U, + "parallel insertion must add one row to the continuous grid"); + const LadderRung &lower = logic->rungs[1]; + require( + lower.cells[2].kind == LadderCellKind::Node + && lower.cells[2].node->id == branch.id + && lower.cells[3].kind == LadderCellKind::Wire, + "the branch row must align the new condition with its selected span"); + require( + connectionAt(*logic, source, lower.id, 2) != nullptr + && connectionAt(*logic, source, lower.id, 4) != nullptr, + "a parallel branch must be bounded by independent left and right edges"); } -void testAppendingWireMovesToNextNetworkAfterTenColumns() +void testParallelBranchReusesExistingEdges() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - for (int index = 0; index < ProjectLimits::kMaximumConditionColumns; ++index) + Fixture fixture; + const std::string source = fixture.addRung(); + const std::string following = fixture.addRung(); + const LogicEditorResult first = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 2, contact(4), true); + const LogicEditorResult second = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 3, contact(5), true); + require(first.succeeded && second.succeeded, + "fixture must place the branch source conditions"); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, source, following, 2, true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, source, following, 4, true).succeeded, + "fixture must create edges at the future branch boundaries"); + + const LogicEditorResult branch = fixture.editor.addParallelBranch( + fixture.logicId, source, {first.id, second.id}, contact(6), true); + require(branch.succeeded, + "parallel insertion must reuse matching split edges"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require(logic->rungs.size() == 3U, + "parallel insertion must add exactly one row"); + const std::string branch_rung = logic->rungs[1].id; + for (int boundary : {2, 4}) { - require(service.appendWire(logic_id, rung_id).succeeded, - "ten horizontal wire cells must fit in one network"); + require( + connectionAt(*logic, source, branch_rung, boundary) != nullptr + && connectionAt(*logic, branch_rung, following, boundary) + != nullptr, + "existing topology and new branch must share one edge per boundary"); } - const LogicEditorResult next = service.appendWire(logic_id, rung_id); - require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U, - "the next appended wire must create the following network"); - const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id; - require(service.findRung(logic_id, rung_id)->condition.has_value() - && conditionColumns(*service.findRung(logic_id, rung_id)->condition) - == ProjectLimits::kMaximumConditionColumns - && service.findRung(logic_id, next_rung_id)->condition.has_value(), - "automatic network rollover must preserve both network contents"); - require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U, - "automatic network rollover must be undone as one edit"); } -void testAppendingWireReusesDeletedGapCells() +void testNodeDeletionAndHistoryAreAtomic() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult full_wire = service.appendWire( - logic_id, rung_id, ProjectLimits::kMaximumConditionColumns); - require(full_wire.succeeded, "gap reuse setup must create a full-width wire"); - require(service.disconnectWires(logic_id, rung_id, {full_wire.id}).succeeded, - "deleting the full-width wire must create reusable gaps"); - - LogicEditorResult current = service.appendWire(logic_id, rung_id); - require(current.succeeded, - "the first appended wire must reuse the first deleted gap cell"); - for (int index = 1; index < ProjectLimits::kMaximumConditionColumns; ++index) + Fixture fixture; + const std::string rung_id = fixture.addRung(); + const LogicEditorResult node = fixture.editor.setConditionAtColumn( + fixture.logicId, rung_id, 0, contact(9), true); + require(node.succeeded, "fixture must place a condition"); + fixture.editor.clearHistory(); + + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, rung_id, 1, 4, true).succeeded, + "one horizontal drag must be one edit"); + require(fixture.editor.canUndo(), "the drag must enter undo history"); + require(fixture.editor.undo().succeeded, "the drag must undo atomically"); + const LadderRung *rung = fixture.editor.findRung( + fixture.logicId, rung_id); + for (int column = 1; column <= 4; ++column) { - current = service.insertWireAfter(logic_id, rung_id, current.id); - require(current.succeeded, - "inserting after a selected wire must reuse the next deleted gap cell"); - require(service.findLogic(logic_id)->rungs.size() == 1U, - "reusing a gap must not create a new network"); + require( + rung->cells[static_cast(column)].kind + == LadderCellKind::Gap, + "undo must restore every cell changed by the drag"); } - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && conditionColumns(*rung->condition) - == ProjectLimits::kMaximumConditionColumns - && gapColumns(*rung->condition) == 0, - "all deleted gap cells must be restored as horizontal wires"); + require(fixture.editor.redo().succeeded, "the drag must redo atomically"); + require( + fixture.editor.removeNode(fixture.logicId, node.id).succeeded, + "deleting a condition node must succeed"); + rung = fixture.editor.findRung(fixture.logicId, rung_id); + require( + rung->cells[0].kind == LadderCellKind::Gap + && !rung->cells[0].node.has_value(), + "a deleted node must leave an editable gap cell"); + + const std::size_t rows_before = fixture.editor.findLogic( + fixture.logicId)->rungs.size(); + require( + !fixture.editor.removeRungs( + fixture.logicId, {rung_id, "missing-rung"}).succeeded, + "a batch containing an invalid row must fail"); + require( + fixture.editor.findLogic(fixture.logicId)->rungs.size() + == rows_before, + "a failed batch edit must not partially mutate the graph"); } -void testStructuredEditingAndNormalization() +void testSelectionDeletionIsAtomic() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - - const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0)); - const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1)); - require(first.succeeded && second.succeeded, "series append must succeed"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 2U, - "two appended nodes must form a series expression"); - - const std::string second_expression_id = second.id; - const LogicEditorResult parallel = service.addParallelBranch( - logic_id, rung_id, {second_expression_id}, contact(2)); - require(parallel.succeeded, "parallel insertion must succeed"); - rung = service.findRung(logic_id, rung_id); - const ConditionExpression *parallel_expression = service.findExpression( - logic_id, rung_id, rung->condition->children.at(1).id); - require(parallel_expression != nullptr - && parallel_expression->kind == ConditionExpressionKind::Parallel, - "selected node must become a parallel expression"); - - const LogicEditorResult nested_series = service.insertConditionAfter( - logic_id, - rung_id, - parallel.id, - contact(3)); - require(nested_series.succeeded, "a parallel branch must accept a series node"); - rung = service.findRung(logic_id, rung_id); - require(rung->condition->kind == ConditionExpressionKind::Series, - "root must remain a series expression"); - const ConditionExpression &nested_parallel_expression = rung->condition->children.at(1); - require(nested_parallel_expression.kind == ConditionExpressionKind::Parallel - && nested_parallel_expression.children.at(1).kind - == ConditionExpressionKind::Series, - "editor must express A AND (B OR (C AND D))"); - - require(service.removeNode(logic_id, nested_series.id).succeeded, - "nested series node deletion must succeed"); - rung = service.findRung(logic_id, rung_id); - require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel - && rung->condition->children.at(1).children.at(1).kind - == ConditionExpressionKind::Series - && gapColumns(*rung->condition) == 1 - && conditionColumns(*rung->condition) == 3, - "deleting a nested node must preserve its column as an explicit gap"); - - require(service.removeNode(logic_id, parallel.id).succeeded, - "parallel leaf deletion must succeed"); - rung = service.findRung(logic_id, rung_id); - require(rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 2U, - "deleting a parallel leaf must preserve the surrounding topology"); - require(gapColumns(*rung->condition) == 2 - && conditionColumns(*rung->condition) == 3, - "each deleted condition must remain as a one-column gap"); - - require(service.setOutput( - logic_id, - rung_id, - CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}) - .succeeded, - "output coil must be set"); - require(service.findLogic(logic_id)->validate(), - "structured editing result must remain a valid draft"); + Fixture fixture; + const std::string upper = fixture.addRung(); + const std::string lower = fixture.addRung(); + require( + fixture.editor.setConditionAtColumn( + fixture.logicId, upper, 0, contact(30), true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, upper, 1, 1, true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + upper, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 31}, CoilMode::Normal}, + true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, upper, lower, 2, true).succeeded, + "fixture must create every selectable object type"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require(logic != nullptr && logic->verticalConnections.size() == 1U, + "fixture must expose the vertical connection id"); + const std::string connection_id = logic->verticalConnections.front().id; + fixture.editor.clearHistory(); + + LogicSelectionDeleteRequest selection; + selection.cells = {{upper, 0}, {upper, 1}}; + selection.outputRungIds = {upper}; + selection.verticalConnectionIds = {connection_id}; + require( + fixture.editor.deleteSelection(fixture.logicId, selection).succeeded, + "one selection delete must remove mixed ladder objects"); + const LadderRung *rung = fixture.editor.findRung(fixture.logicId, upper); + logic = fixture.editor.findLogic(fixture.logicId); + require( + rung != nullptr + && rung->cells[0].kind == LadderCellKind::Gap + && rung->cells[1].kind == LadderCellKind::Gap + && !rung->output.has_value() + && logic->verticalConnections.empty(), + "selection delete must clear every requested object together"); + require( + fixture.editor.undo().succeeded, + "mixed selection deletion must create one undo entry"); + rung = fixture.editor.findRung(fixture.logicId, upper); + logic = fixture.editor.findLogic(fixture.logicId); + require( + rung->cells[0].kind == LadderCellKind::Node + && rung->cells[1].kind == LadderCellKind::Wire + && rung->output.has_value() + && logic->verticalConnections.size() == 1U, + "one undo must restore the complete mixed selection"); + require( + fixture.editor.redo().succeeded, + "mixed selection deletion must redo as one entry"); + rung = fixture.editor.findRung(fixture.logicId, upper); + require( + rung->cells[0].kind == LadderCellKind::Gap + && rung->cells[1].kind == LadderCellKind::Gap + && !rung->output.has_value() + && fixture.editor.findLogic(fixture.logicId) + ->verticalConnections.empty(), + "one redo must delete the complete mixed selection again"); } -void testRangeParallelInsertion() +void testInvalidSelectionDeletionDoesNotMutateOrRecordHistory() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - service.appendCondition(logic_id, rung_id, contact(0)); - service.appendCondition(logic_id, rung_id, contact(1)); - - service.appendCondition(logic_id, rung_id, contact(2)); - const LogicEditorResult branch = service.addParallelBranch( - logic_id, rung_id, {"contact-2", "contact-3"}, contact(3)); - require(branch.succeeded, "a continuous series range must accept a parallel branch"); - const ConditionExpression &root = *service.findRung(logic_id, rung_id)->condition; - require(root.kind == ConditionExpressionKind::Series - && root.children.size() == 2U - && root.children.at(1).kind == ConditionExpressionKind::Parallel - && root.children.at(1).children.front().kind - == ConditionExpressionKind::Series, - "range insertion must express A AND ((B AND C) OR D)"); - require(root.validate(), "range insertion must preserve normalized topology"); - - const LogicEditorResult invalid = service.addParallelBranch( - logic_id, rung_id, {"contact-1", "contact-3"}, contact(4)); - require(!invalid.succeeded - && invalid.error == LogicEditorError::InvalidOperation, - "a non-contiguous selection must be rejected"); + Fixture fixture; + const std::string rung_id = fixture.addRung(); + require( + fixture.editor.setConditionAtColumn( + fixture.logicId, rung_id, 0, contact(40), true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, rung_id, 1, 1, true).succeeded, + "fixture must create valid objects before an invalid mixed delete"); + fixture.editor.clearHistory(); + + LogicSelectionDeleteRequest selection; + selection.cells = {{rung_id, 0}, {rung_id, 1}}; + selection.verticalConnectionIds = {"missing-vertical"}; + require( + !fixture.editor.deleteSelection(fixture.logicId, selection).succeeded, + "a mixed selection containing an invalid object must fail"); + const LadderRung *rung = fixture.editor.findRung( + fixture.logicId, rung_id); + require( + rung->cells[0].kind == LadderCellKind::Node + && rung->cells[1].kind == LadderCellKind::Wire, + "an invalid mixed deletion must not partially clear valid cells"); + require( + !fixture.editor.canUndo(), + "an invalid mixed deletion must not create an undo entry"); } -void testParallelBranchGridInsertion() +void testCommandInputMapsToGridCoordinates() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult first = service.appendCondition( - logic_id, rung_id, contact(0)); - const LogicEditorResult second = service.appendCondition( - logic_id, rung_id, contact(1)); - const LogicEditorResult third = service.appendCondition( - logic_id, rung_id, contact(2)); - const LogicEditorResult branch = service.addParallelBranch( - logic_id, rung_id, {first.id, second.id, third.id}, contact(10)); - require(branch.succeeded, - "parallel grid insertion setup must create a short lower branch"); - - service.clearHistory(); - const LadderRung *padded_rung = service.findRung(logic_id, rung_id); - const ConditionExpression &padded_lower = padded_rung->condition->children.at(1); - require(padded_lower.kind == ConditionExpressionKind::Series - && padded_lower.children.size() == 2U - && padded_lower.children.back().kind == ConditionExpressionKind::Wire - && padded_lower.children.back().wire->columnSpan == 2, - "a short parallel branch must persist its two padding cells as a wire"); - const LogicEditorResult inserted = service.replaceWireColumnWithCondition( - logic_id, - rung_id, - padded_lower.children.back().id, - 1, - contact(12)); - require(inserted.succeeded, - "a persisted parallel branch wire cell must accept a condition"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Parallel, - "branch grid insertion must preserve the surrounding parallel expression"); - const ConditionExpression &lower = rung->condition->children.at(1); - require(lower.kind == ConditionExpressionKind::Series - && lower.children.size() == 3U - && lower.children.at(0).node->id == branch.id - && lower.children.at(1).kind == ConditionExpressionKind::Wire - && lower.children.at(1).wire->columnSpan == 1 - && lower.children.at(2).node->id == inserted.id, - "a distant branch cell must persist only the required gap and new condition"); - - require(service.undo().succeeded, - "parallel branch grid insertion must be one undoable edit"); - rung = service.findRung(logic_id, rung_id); - require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series - && rung->condition->children.at(1).children.front().node->id == branch.id - && rung->condition->children.at(1).children.back().kind - == ConditionExpressionKind::Wire - && rung->condition->children.at(1).children.back().wire->columnSpan == 2, - "undo must restore the original branch and its explicit padding wire"); - - service.clearHistory(); - const bool modified_before = project_service.isModified(); - const LogicEditorResult invalid = service.insertConditionInBranchAtColumn( - logic_id, rung_id, branch.id, 3, contact(13)); - require(!invalid.succeeded && !service.canUndo() - && project_service.isModified() == modified_before - && service.findNode(logic_id, inserted.id) == nullptr, - "a cell outside the visible branch padding must fail atomically"); -} + Fixture fixture; + LogicCommandService commands(fixture.editor); + const std::string rung_id = fixture.addRung(); -void testStructuredWireEditing() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - service.appendCondition(logic_id, rung_id, contact(0)); - service.appendCondition(logic_id, rung_id, contact(1)); - service.appendCondition(logic_id, rung_id, contact(2)); - - const LogicEditorResult branch = service.addParallelWireBranch( - logic_id, rung_id, {"contact-2", "contact-3"}); - require(branch.succeeded && branch.id == "wire-1", - "a continuous range must accept a structured horizontal bypass"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.at(1).kind - == ConditionExpressionKind::Parallel, - "a vertical connection must produce a parallel expression"); - const ConditionExpression &wire = - rung->condition->children.at(1).children.at(1); - require(wire.kind == ConditionExpressionKind::Wire - && wire.wire->columnSpan == 2, - "the bypass wire span must match the selected two-column range"); - - const LogicEditorResult replacement = service.replaceWireWithCondition( - logic_id, rung_id, branch.id, contact(3)); - require(replacement.succeeded && replacement.id == "contact-4", - "a selected wire must be replaceable by a configured node type"); - const LogicEditorResult extension = service.insertWireAfter( - logic_id, rung_id, replacement.id); - require(extension.succeeded && extension.id == "wire-2", - "replacing one cell of a multi-cell wire must retain the original id on the remaining cell"); - - rung = service.findRung(logic_id, rung_id); - const ConditionExpression *extended_branch = service.findExpression( - logic_id, rung_id, rung->condition->children.at(1).children.at(1).id); - require(extended_branch != nullptr - && extended_branch->kind == ConditionExpressionKind::Series - && extended_branch->children.at(1).kind - == ConditionExpressionKind::Wire, - "inserting a horizontal wire after a branch node must preserve structure"); - const std::string extended_branch_id = extended_branch->id; - require(!service.addParallelWireBranch( - logic_id, rung_id, {"contact-1", "contact-3"}).succeeded, - "non-contiguous wire connection targets must be rejected"); - - require(service.removeExpressions( - logic_id, rung_id, {extended_branch_id}).succeeded, - "deleting a selected vertical connection branch must succeed atomically"); - rung = service.findRung(logic_id, rung_id); - require(rung->condition.has_value() - && conditionNodes(*rung->condition) == 3 - && conditionColumns(*rung->condition) == 4 - && wireColumns(*rung->condition) == 1 - && service.findNode(logic_id, replacement.id) == nullptr, - "removing a bypass branch must keep the materialized padding wire without reconnecting positions"); - require(service.undo().succeeded - && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, - "wire branch deletion must participate in ladder undo history"); -} + require( + LogicCommandService::parse("ldi m4000").succeeded, + "command parsing must remain case-insensitive"); + require( + !LogicCommandService::parse("LD D0").succeeded, + "contact commands must reject D addresses"); -void testWireCellParallelSelectionUsesExactColumns() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult source = service.appendWire( - logic_id, rung_id, 3); - require(source.succeeded, "wire-cell parallel setup must create a three-column wire"); - - const LogicEditorResult branch = service.addParallelWireBranchAtCells( - logic_id, + LogicCommandRequest request; + request.logicId = fixture.logicId; + request.text = "LD M12"; + request.target = { + LogicCommandTargetKind::EmptyColumn, rung_id, - {{source.id, 0}, {source.id, 1}}); - require(branch.succeeded, - "a selected two-cell wire range must create a parallel bypass"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 2U, - "a partial wire selection must preserve the surrounding series layout"); - const ConditionExpression ¶llel = rung->condition->children.front(); - require(parallel.kind == ConditionExpressionKind::Parallel - && parallel.children.size() == 2U - && parallel.children.front().kind == ConditionExpressionKind::Wire - && parallel.children.front().wire->columnSpan == 2 - && parallel.children.back().kind == ConditionExpressionKind::Wire - && parallel.children.back().wire->columnSpan == 2 - && rung->condition->children.back().kind - == ConditionExpressionKind::Wire - && rung->condition->children.back().wire->columnSpan == 1 - && conditionColumns(*rung->condition) == 3, - "the new bypass width must match the selected two cells, not the full source wire"); - - service.clearHistory(); - const bool modified_before = project_service.isModified(); - const LogicEditorResult non_contiguous = - service.addParallelWireBranchAtCells( - logic_id, - rung_id, - {{source.id, 0}, {source.id, 2}}); - require(!non_contiguous.succeeded - && project_service.isModified() == modified_before - && !service.canUndo(), - "non-contiguous wire-cell selections must fail without mutation"); + {}, + 5}; + const LogicCommandResult loaded = commands.execute(request); + require(loaded.succeeded + && loaded.hasNextCursor + && loaded.nextCursor.rungId == rung_id + && loaded.nextCursor.column == 6 + && !loaded.nextCursor.output, + "LD must be written to the selected grid cell and advance right"); + const LadderCell *cell = fixture.editor.findCell( + fixture.logicId, rung_id, 5); + require( + cell != nullptr && cell->kind == LadderCellKind::Node + && cell->node->id == loaded.id, + "command input must preserve the target row and column"); + + request.text = "LDI M13"; + request.target.kind = LogicCommandTargetKind::ExistingNode; + request.target.expressionId = loaded.id; + const LogicCommandResult replaced = commands.execute(request); + cell = fixture.editor.findCell(fixture.logicId, rung_id, 5); + require( + replaced.succeeded && replaced.id == loaded.id + && cell->node->id == loaded.id + && std::get(cell->node->config).mode + == ContactMode::NormallyClosed, + "editing an existing command must preserve its node identity"); + + request.text = "OUT M20"; + request.target.kind = LogicCommandTargetKind::Output; + const LogicCommandResult output = commands.execute(request); + require(output.succeeded + && output.hasNextCursor + && output.nextCursor.column == 0 + && output.nextCursor.rungId != rung_id, + "OUT must target the row output slot and advance to the next row"); + require( + fixture.editor.findRung(fixture.logicId, rung_id)->output->id + == output.id, + "the command must create the configured output node"); + + request.text = "AND M21"; + require( + !commands.execute(request).succeeded, + "condition commands must not be accepted in the output slot"); } -void testWireCellParallelSelectionAcrossAdjacentWires() +void testProjectRungLimitAppliesToBranchAndPaste() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - std::vector wire_ids; - for (int index = 0; index < 3; ++index) + Fixture fixture; + const std::string source = fixture.addRung(); + const LogicEditorResult node = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 0, contact(20), true); + require(node.succeeded, "fixture must create a branch source node"); + const LadderRung source_copy = *fixture.editor.findRung( + fixture.logicId, source); + + Project &project = fixture.projects.editProject(); + std::size_t remaining = ProjectLimits::kMaximumRungsPerProject - 1U; + std::size_t logic_index = 2U; + std::size_t rung_index = 1U; + while (remaining > 0U) { - const LogicEditorResult wire = service.appendWire(logic_id, rung_id); - require(wire.succeeded, - "adjacent wire setup must create each one-column segment"); - wire_ids.push_back(wire.id); + ControlLogic extra; + extra.id = "limit-logic-" + std::to_string(logic_index); + extra.name = "Limit logic " + std::to_string(logic_index); + const std::size_t count = std::min( + remaining, ProjectLimits::kMaximumRungsPerLogic); + for (std::size_t index = 0U; index < count; ++index, ++rung_index) + { + LadderRung rung; + rung.id = "limit-rung-" + std::to_string(rung_index); + rung.name = "Limit row " + std::to_string(rung_index); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + rung.id + "-cell-" + std::to_string(column), + LadderCellKind::Gap, + std::nullopt}); + } + extra.rungs.push_back(std::move(rung)); + } + project.controlLogics.push_back(std::move(extra)); + remaining -= count; + ++logic_index; } - - const LogicEditorResult branch = service.addParallelWireBranchAtCells( - logic_id, - rung_id, - {{wire_ids.at(0), 0}, {wire_ids.at(1), 0}, {wire_ids.at(2), 0}}); - require(branch.succeeded, - "visually continuous adjacent wire cells must create one bypass"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Parallel - && rung->condition->children.size() == 2U - && rung->condition->children.front().kind - == ConditionExpressionKind::Wire - && rung->condition->children.front().wire->columnSpan == 3 - && rung->condition->children.back().kind - == ConditionExpressionKind::Wire - && rung->condition->children.back().wire->columnSpan == 3, - "three adjacent one-column wires must become a three-column parallel range"); + require(project.validate(), + "the project-wide rung limit fixture must itself be valid"); + + require( + !fixture.editor.addParallelBranch( + fixture.logicId, source, {node.id}, contact(21), true).succeeded, + "parallel insertion must respect the project-wide rung limit"); + require( + !fixture.editor.pasteRung(fixture.logicId, source_copy).succeeded, + "row paste must respect the project-wide rung limit"); + require( + fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U, + "failed limit checks must not partially add a row"); } -void testBatchDeleteAllNodesInParallelBranch() +void testConfiguredRowLimit() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - - const LogicEditorResult first = service.appendCondition( - logic_id, rung_id, contact(0)); - const LogicEditorResult second = service.appendCondition( - logic_id, rung_id, contact(1)); - const LogicEditorResult third = service.appendCondition( - logic_id, rung_id, contact(2)); - const LogicEditorResult fourth = service.appendCondition( - logic_id, rung_id, contact(3)); - require(first.succeeded && second.succeeded && third.succeeded - && fourth.succeeded, - "parallel batch deletion setup contacts must be created"); - - const LogicEditorResult branch = service.addParallelBranch( - logic_id, rung_id, - {second.id, third.id, fourth.id}, - contact(10)); - require(branch.succeeded, - "parallel batch deletion setup branch must be created"); - - require(service.removeNodes( - logic_id, {second.id, third.id, fourth.id}) - .succeeded, - "deleting every node in a parallel branch as one batch must succeed"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->validate(), - "batch deletion must leave a valid normalized ladder expression"); - require(rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 2U - && rung->condition->children.at(0).kind - == ConditionExpressionKind::Node - && rung->condition->children.at(1).kind - == ConditionExpressionKind::Parallel - && conditionNodes(*rung->condition) == 2 - && gapColumns(*rung->condition) == 3 - && conditionColumns(*rung->condition) == 4, - "deleted parallel conditions must remain as gaps without collapsing the branch"); - require(service.findNode(logic_id, second.id) == nullptr - && service.findNode(logic_id, third.id) == nullptr - && service.findNode(logic_id, fourth.id) == nullptr, - "all selected parallel branch nodes must be removed"); - require(service.undo().succeeded, - "parallel batch deletion must be undoable"); - require(service.findNode(logic_id, second.id) != nullptr - && service.findNode(logic_id, third.id) != nullptr - && service.findNode(logic_id, fourth.id) != nullptr, - "undo must restore every deleted parallel branch node"); + ProjectLimitSettings limits = defaultProjectLimitSettings(); + limits.maximumRungsPerLogic = 1U; + TestSupport::InMemoryProjectStorage storage; + ProjectService projects(storage, limits); + LogicEditorService editor(projects); + const std::string logic_id = editor.ensureDefaultLogic().id; + require(editor.addRung(logic_id).succeeded, + "the configured row limit must permit its boundary value"); + require(!editor.addRung(logic_id).succeeded, + "the configured row limit must reject one extra row"); } -void testConditionColumnLimit() +void testFirstEditOnEmptyLogicIsAtomic() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); + Fixture fixture; + fixture.editor.clearHistory(); + + const LogicEditorResult condition = fixture.editor.appendCondition( + fixture.logicId, {}, contact(11), true); + require(condition.succeeded, + "placing the first condition must create the first row"); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require(logic->rungs.size() == 1U + && logic->rungs.front().cells.front().node.has_value(), + "the first condition must be stored in the first grid cell"); + require(fixture.editor.canUndo(), + "creating the first row and condition must be one history entry"); + require(fixture.editor.undo().succeeded + && fixture.editor.findLogic(fixture.logicId)->rungs.empty(), + "undo must remove the atomically created first condition and row"); + require(fixture.editor.redo().succeeded + && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U, + "redo must restore the atomically created first condition and row"); + + fixture.editor.clearHistory(); + const LogicEditorResult wire = fixture.editor.appendWire( + fixture.logicId, {}, 1); + require(wire.succeeded, + "appending a wire to an empty logic must create a first row"); + logic = fixture.editor.findLogic(fixture.logicId); + require(logic->rungs.size() == 2U + && logic->rungs.back().cells.back().kind == LadderCellKind::Wire, + "an empty-target wire must be placed in the new row"); +} - for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) +void testCursorAdvanceAndOutputTransaction() +{ + Fixture fixture; + fixture.editor.clearHistory(); + + LogicEditResult applied = fixture.editor.applyConditionAndAdvance( + fixture.logicId, {}, contact(50), true); + require( + applied.edit.succeeded + && !applied.nextCursor.output + && applied.nextCursor.column == 1 + && !applied.nextCursor.rungId.empty(), + "the first condition must create a row and advance to column two"); + const std::string first_rung = applied.nextCursor.rungId; + require( + fixture.editor.findRung(fixture.logicId, first_rung) + ->cells.front().kind == LadderCellKind::Node, + "the first condition must be written to column one"); + require( + fixture.editor.undo().succeeded + && fixture.editor.findLogic(fixture.logicId)->rungs.empty(), + "one undo must remove the first condition and its atomically created row"); + require(fixture.editor.redo().succeeded, + "the first cursor edit must redo atomically"); + + fixture.editor.clearHistory(); + LogicEditCursor cursor{first_rung, 1, false}; + for (int column = 1; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded, - "the first ten condition columns must be editable"); + applied = fixture.editor.applyWireAndAdvance( + fixture.logicId, cursor); + require(applied.edit.succeeded, + "each wire cursor edit must succeed"); + cursor = applied.nextCursor; } - require(project_service.saveAs("logic-editor-condition-limit.json").succeeded, - "the ten-column network must be saveable before testing overflow"); - require(!project_service.isModified(), - "saving the ten-column network must clear the modified state"); - const LogicEditorResult overflow = service.appendCondition( - logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns)); - require(!overflow.succeeded - && overflow.error == LogicEditorError::InvalidOperation, - "the eleventh condition column must be rejected by the editor service"); - require(!project_service.isModified(), - "a failed eleventh-column edit must preserve the saved state"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() - == static_cast( - ProjectLimits::kMaximumConditionColumns), - "a rejected eleventh column must leave the ten-column network unchanged"); - - require(service.setOutput( - logic_id, - rung_id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, - true) - .succeeded - && project_service.isModified(), - "a successful edit must make the project modified again"); - require(!service.appendCondition( - logic_id, rung_id, - contact(ProjectLimits::kMaximumConditionColumns)) - .succeeded - && project_service.isModified(), - "a failed edit must preserve an existing modified state"); + require( + cursor.output + && cursor.column == ProjectLimits::kMaximumConditionColumns, + "the tenth condition cell must advance to the output slot"); + + fixture.editor.clearHistory(); + const LogicEditResult output = fixture.editor.applyOutputAndAdvance( + fixture.logicId, + cursor, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 51}, CoilMode::Normal}, + true); + require( + output.edit.succeeded + && !output.nextCursor.output + && output.nextCursor.column == 0 + && output.nextCursor.rungId != first_rung + && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 2U, + "a final-network output must append a new row and move to its first cell"); + require( + fixture.editor.undo().succeeded + && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 1U + && !fixture.editor.findRung(fixture.logicId, first_rung) + ->output.has_value(), + "one undo must remove both the output and its automatically appended row"); } -void testUnconditionalOutputEditing() +void testOutputAutomaticallyCompletesTrailingWires() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - - require(service.setOutput( - logic_id, - rung_id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, - true) - .succeeded, - "the editor must allow a coil before any condition is added"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Wire - && rung->condition->wire->columnSpan - == ProjectLimits::kMaximumConditionColumns - && rung->output.has_value() && rung->validateForRunning(), - "an editor-created unconditional network must use a full-width wire"); - - const std::string contact_rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult contact_result = service.appendCondition( - logic_id, contact_rung_id, contact(0)); - require(contact_result.succeeded - && service.updateNodeConfig( - logic_id, contact_result.id, contact(0)).succeeded - && service.setOutput( - logic_id, - contact_rung_id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal}, - true).succeeded, - "a contact followed by an output must be editable"); - const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id); - require(contact_rung != nullptr && contact_rung->condition.has_value() - && conditionNodes(*contact_rung->condition) == 1 - && wireColumns(*contact_rung->condition) == 9 - && conditionColumns(*contact_rung->condition) == 10 - && contact_rung->validateForRunning(), - "a contact followed by an output must persist the remaining nine wire cells"); + Fixture direct; + direct.editor.clearHistory(); + const LogicEditResult direct_output = direct.editor.applyOutputAndAdvance( + direct.logicId, + {}, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 54}, CoilMode::Normal}, + true); + const ControlLogic *direct_logic = direct.editor.findLogic(direct.logicId); + require( + direct_output.edit.succeeded && direct_logic->rungs.size() == 2U + && direct_logic->rungs.front().output.has_value() + && std::all_of( + direct_logic->rungs.front().cells.cbegin(), + direct_logic->rungs.front().cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Wire + && !cell.node.has_value(); + }), + "a direct output on empty logic must atomically create ten wires"); + requireEmptyGrid(direct_logic->rungs.back()); + require( + direct.editor.undo().succeeded + && direct.editor.findLogic(direct.logicId)->rungs.empty(), + "one undo must remove the direct output, its wires, and the next row"); + + Fixture trailing; + const std::string rung_id = trailing.addRung(); + require( + trailing.editor.setConditionAtColumn( + trailing.logicId, rung_id, 0, contact(55), true).succeeded + && trailing.editor.setConditionAtColumn( + trailing.logicId, rung_id, 2, contact(56), true).succeeded, + "the trailing-wire fixture must leave one intentional middle gap"); + trailing.editor.clearHistory(); + const LogicEditResult trailing_output = trailing.editor.applyOutputAndAdvance( + trailing.logicId, + {rung_id, ProjectLimits::kMaximumConditionColumns, true}, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 57}, CoilMode::Normal}, + true); + const LadderRung *completed = trailing.editor.findRung( + trailing.logicId, rung_id); + require( + trailing_output.edit.succeeded + && completed->cells[1].kind == LadderCellKind::Gap + && std::all_of( + completed->cells.cbegin() + 3, + completed->cells.cend(), + [](const LadderCell &cell) + { + return cell.kind == LadderCellKind::Wire; + }), + "output insertion must fill trailing gaps without bridging a middle gap"); + require( + trailing.editor.undo().succeeded + && trailing.editor.findLogic(trailing.logicId)->rungs.size() == 1U + && trailing.editor.findRung(trailing.logicId, rung_id) + ->cells[3].kind == LadderCellKind::Gap + && !trailing.editor.findRung(trailing.logicId, rung_id) + ->output.has_value(), + "trailing wires, output, and appended row must undo together"); } -void testExplicitGapEditing() +void testOutputAdvancesPastTheWholeNetworkGroup() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - require(service.setOutput( - logic_id, - rung_id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal}, - true).succeeded, - "gap editing setup must create an explicit full-width wire"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - const std::string full_wire_id = rung->condition->id; - require(service.disconnectWireCells( - logic_id, rung_id, {{full_wire_id, 4}}).succeeded, - "deleting one wire cell must create a gap"); - rung = service.findRung(logic_id, rung_id); - require(rung->validate() && !rung->validateForRunning() - && conditionColumns(*rung->condition) == 10 - && wireColumns(*rung->condition) == 9 - && gapColumns(*rung->condition) == 1, - "a disconnected wire cell must preserve width and block runtime"); - - const ConditionExpression *gap = firstExpressionOfKind( - *rung->condition, ConditionExpressionKind::Gap); - require(gap != nullptr, "the disconnected cell must expose a gap expression"); - const std::string first_gap_id = gap->id; - require(service.replaceGapColumnWithWire( - logic_id, rung_id, first_gap_id, 0).succeeded, - "a gap cell must be repairable with a real wire"); - rung = service.findRung(logic_id, rung_id); - require(gapColumns(*rung->condition) == 0 - && wireColumns(*rung->condition) == 10 - && rung->validateForRunning(), - "repairing the gap with a wire must restore a runnable path"); - - const ConditionExpression *wire = firstExpressionOfKind( - *rung->condition, ConditionExpressionKind::Wire); - require(wire != nullptr, "the repaired path must expose a wire expression"); - const std::string repaired_wire_id = wire->id; - require(service.disconnectWireCells( - logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded, - "a repaired wire cell must be disconnectable again"); - rung = service.findRung(logic_id, rung_id); - gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap); - require(gap != nullptr, "the second disconnection must expose a gap expression"); - const std::string second_gap_id = gap->id; - const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition( - logic_id, rung_id, second_gap_id, 0, contact(31)); - require(inserted_result.succeeded - && service.updateNodeConfig( - logic_id, inserted_result.id, contact(31)).succeeded, - "a gap cell must be replaceable with a contact"); - rung = service.findRung(logic_id, rung_id); - const ConditionExpression *inserted = firstExpressionOfKind( - *rung->condition, ConditionExpressionKind::Node); - require(inserted != nullptr && gapColumns(*rung->condition) == 0 - && conditionNodes(*rung->condition) == 1 - && conditionColumns(*rung->condition) == 10 - && rung->validateForRunning(), - "a contact inserted into a gap must preserve the ten-column path"); - const std::string inserted_node_id = inserted->node->id; - require(service.removeNode(logic_id, inserted_node_id).succeeded, - "the inserted contact must be removable"); - rung = service.findRung(logic_id, rung_id); - require(conditionNodes(*rung->condition) == 0 - && gapColumns(*rung->condition) == 1 - && conditionColumns(*rung->condition) == 10 - && !rung->validateForRunning(), - "deleting a contact must restore a gap instead of reconnecting the path"); - - const std::string multi_rung_id = makeEmptyRung(service, logic_id); - require(service.setOutput( - logic_id, - multi_rung_id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal}, - true).succeeded, - "multi-gap setup must create a full-width wire"); - const std::string multi_wire_id = service.findRung( - logic_id, multi_rung_id)->condition->id; - require(service.disconnectWireCells( - logic_id, - multi_rung_id, - {{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded, - "multiple cells in one wire must disconnect atomically"); - const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id); - require(conditionColumns(*multi_rung->condition) == 10 - && wireColumns(*multi_rung->condition) == 8 - && gapColumns(*multi_rung->condition) == 2 - && !multi_rung->validateForRunning(), - "multiple disconnected cells must retain both explicit gaps"); + Fixture fixture; + const std::string upper = fixture.addRung(); + const std::string branch = fixture.addRung(); + const std::string next_network = fixture.addRung(); + require( + fixture.editor.setVerticalConnection( + fixture.logicId, upper, branch, 0, true).succeeded, + "fixture must connect two rows into one network group"); + fixture.editor.clearHistory(); + + const LogicEditResult output = fixture.editor.applyOutputAndAdvance( + fixture.logicId, + {upper, ProjectLimits::kMaximumConditionColumns, true}, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 52}, CoilMode::Normal}, + true); + require( + output.edit.succeeded + && output.nextCursor.rungId == next_network + && output.nextCursor.column == 0 + && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 3U, + "an output must jump after every row in its connected network group"); } -void testColumnTargetedConditionInsertion() +void testOutputLimitFailureLeavesNoPartialEdit() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string first_rung_id = makeEmptyRung(service, logic_id); - - require(service.insertConditionAtColumn( - logic_id, first_rung_id, 4, contact(4)).succeeded, - "an empty network must accept a condition at the selected fifth column"); - const LadderRung *rung = service.findRung(logic_id, first_rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 2U - && rung->condition->children.front().kind - == ConditionExpressionKind::Wire - && rung->condition->children.front().wire->columnSpan == 4 - && rung->condition->children.back().kind - == ConditionExpressionKind::Node - && rung->validate() - && conditionColumns(*rung->condition) == 5, - "column insertion must preserve the requested horizontal position"); - - require(service.insertConditionAtColumn( - logic_id, first_rung_id, 2, contact(2)).error - == LogicEditorError::InvalidOperation, - "inserting into an already occupied column must be rejected atomically"); - require(service.insertConditionAtColumn( - logic_id, first_rung_id, 10, contact(10)).error - == LogicEditorError::InvalidOperation, - "the eleventh condition column must be rejected"); - require(service.insertConditionAtColumn( - logic_id, first_rung_id, -1, contact(10)).error - == LogicEditorError::InvalidOperation, - "a negative grid column must be rejected"); - require(service.insertConditionAtColumn( - logic_id, - first_rung_id, - 5, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}) - .error == LogicEditorError::InvalidNode, - "a condition grid slot must reject output instructions"); - - const LogicEditorResult second_rung = service.addRung(logic_id); - require(second_rung.succeeded, - "column insertion test must create a second empty network"); - require(service.setOutput( - logic_id, - second_rung.id, - CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, - true) - .succeeded, - "an output-only network must be configurable before adding a condition"); - const std::string output_wire_id = service.findRung( - logic_id, second_rung.id)->condition->id; - require(service.replaceWireColumnWithCondition( - logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded, - "the first wire cell must accept a contact on an unconditional network"); - const LadderRung *output_rung = service.findRung(logic_id, second_rung.id); - require(output_rung != nullptr && output_rung->condition.has_value() - && output_rung->condition->kind == ConditionExpressionKind::Series - && conditionNodes(*output_rung->condition) == 1 - && wireColumns(*output_rung->condition) == 9 - && conditionColumns(*output_rung->condition) == 10 - && output_rung->output.has_value() - && output_rung->validate(), - "condition insertion must keep the independent output slot intact"); - - const LogicEditorResult last_column_rung = service.addRung(logic_id); - require(last_column_rung.succeeded - && service.insertConditionAtColumn( - logic_id, last_column_rung.id, 9, contact(9)).succeeded, - "the tenth condition column must remain a valid insertion target"); - const LadderRung *full_width = service.findRung( - logic_id, last_column_rung.id); - require(full_width != nullptr && full_width->condition.has_value() - && full_width->condition->kind == ConditionExpressionKind::Series - && full_width->condition->children.front().wire->columnSpan == 9 - && conditionColumns(*full_width->condition) == 10, - "last-column insertion must fill exactly the ten-column condition area"); - require(!service.appendCondition( - logic_id, last_column_rung.id, contact(10)).succeeded - && conditionColumns(*service.findRung( - logic_id, last_column_rung.id)->condition) == 10, - "a full-width grid must reject another condition without partial changes"); + ProjectLimitSettings limits = defaultProjectLimitSettings(); + limits.maximumRungsPerLogic = 1U; + TestSupport::InMemoryProjectStorage storage; + ProjectService projects(storage, limits); + LogicEditorService editor(projects); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string rung_id = editor.addRung(logic_id).id; + editor.clearHistory(); + + const LogicEditResult output = editor.applyOutputAndAdvance( + logic_id, + {rung_id, ProjectLimits::kMaximumConditionColumns, true}, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 53}, CoilMode::Normal}, + true); + require( + !output.edit.succeeded + && !editor.findRung(logic_id, rung_id)->output.has_value() + && editor.findLogic(logic_id)->rungs.size() == 1U + && !editor.canUndo(), + "a row-limit failure must not leave the output or an undo record behind"); } -void testWireColumnReplacement() +void testSingleWireClipboardPasteAndUndo() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4); - require(wire.succeeded, - "wire-column replacement test must create a four-column wire"); - - require(service.replaceWireColumnWithCondition( - logic_id, rung_id, wire.id, 1, - ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - ContactMode::NormallyClosed}) - .succeeded, - "a selected wire cell must be replaceable without removing adjacent cells"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 3U - && rung->condition->children.front().wire->columnSpan == 1 - && rung->condition->children.at(1).kind - == ConditionExpressionKind::Node - && std::get( - rung->condition->children.at(1).node->config).mode - == ContactMode::NormallyClosed - && rung->condition->children.back().wire->columnSpan == 2 - && conditionColumns(*rung->condition) == 4, - "wire-cell replacement must split the wire around the new contact"); - - const std::vector before_invalid = - project_service.project().controlLogics; - require(service.replaceWireColumnWithCondition( - logic_id, - rung_id, - rung->condition->children.front().id, - 1, - contact(2)) - .error == LogicEditorError::InvalidOperation, - "an out-of-range wire-cell offset must be rejected"); - require(project_service.project().controlLogics.size() == before_invalid.size() - && conditionColumns(*service.findRung( - logic_id, rung_id)->condition) == 4, - "a rejected wire-cell replacement must leave the network width unchanged"); - - const LogicEditorResult first_cell_rung = service.addRung(logic_id); - const LogicEditorResult first_cell_wire = service.appendWire( - logic_id, first_cell_rung.id, 4); - require(first_cell_rung.succeeded && first_cell_wire.succeeded - && service.replaceWireColumnWithCondition( - logic_id, - first_cell_rung.id, - first_cell_wire.id, - 0, - contact(10)) - .succeeded, - "the first cell of a multi-column wire must be replaceable"); - const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id); - require(first_cell != nullptr && first_cell->condition.has_value() - && first_cell->condition->kind == ConditionExpressionKind::Series - && first_cell->condition->children.size() == 2U - && first_cell->condition->children.front().kind - == ConditionExpressionKind::Node - && first_cell->condition->children.back().kind - == ConditionExpressionKind::Wire - && first_cell->condition->children.back().wire->columnSpan == 3, - "first-cell replacement must preserve the trailing wire cells"); - require(service.undo().succeeded, - "wire-cell replacement must be one undoable edit"); - const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id); - require(undone != nullptr && undone->condition.has_value() - && undone->condition->kind == ConditionExpressionKind::Wire - && undone->condition->wire->columnSpan == 4, - "undo must restore the original unsplit wire"); - require(service.redo().succeeded, - "wire-cell replacement must be redoable"); - const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id); - require(redone != nullptr && redone->condition.has_value() - && redone->condition->kind == ConditionExpressionKind::Series - && conditionColumns(*redone->condition) == 4, - "redo must restore the split wire without changing its width"); - const std::string redone_trailing_wire_id = - redone->condition->children.back().id; - - const LogicEditorResult last_cell_rung = service.addRung(logic_id); - const LogicEditorResult last_cell_wire = service.appendWire( - logic_id, last_cell_rung.id, 4); - require(last_cell_rung.succeeded && last_cell_wire.succeeded - && service.replaceWireColumnWithCondition( - logic_id, - last_cell_rung.id, - last_cell_wire.id, - 3, - contact(11)) - .succeeded, - "the last cell of a multi-column wire must be replaceable"); - const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id); - require(last_cell != nullptr && last_cell->condition.has_value() - && last_cell->condition->kind == ConditionExpressionKind::Series - && last_cell->condition->children.size() == 2U - && last_cell->condition->children.front().kind - == ConditionExpressionKind::Wire - && last_cell->condition->children.front().wire->columnSpan == 3 - && last_cell->condition->children.back().kind - == ConditionExpressionKind::Node, - "last-cell replacement must preserve the leading wire cells"); - - const LogicEditorResult single_cell_rung = service.addRung(logic_id); - const LogicEditorResult single_cell_wire = service.appendWire( - logic_id, single_cell_rung.id, 1); - require(single_cell_rung.succeeded && single_cell_wire.succeeded - && service.replaceWireColumnWithCondition( - logic_id, - single_cell_rung.id, - single_cell_wire.id, - 0, - contact(12)) - .succeeded, - "a one-column wire must use the same grid-cell replacement API"); - const LadderRung *single_cell = service.findRung( - logic_id, single_cell_rung.id); - require(single_cell != nullptr && single_cell->condition.has_value() - && single_cell->condition->kind == ConditionExpressionKind::Node, - "one-column wire replacement must normalize directly to a condition node"); - - require(service.replaceWireColumnWithCondition( - logic_id, - first_cell_rung.id, - redone_trailing_wire_id, - 0, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 13}, CoilMode::Set}) - .error == LogicEditorError::InvalidNode, - "wire grid cells must reject output instructions"); - require(service.replaceWireColumnWithCondition( - logic_id, - first_cell_rung.id, - "missing-wire", - 0, - contact(13)) - .error == LogicEditorError::ExpressionNotFound, - "wire grid replacement must reject an unknown wire without mutation"); + Fixture fixture; + const std::string source = fixture.addRung(); + const std::string target = fixture.addRung(); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, source, 2, 2, true).succeeded, + "fixture must create one copyable wire cell"); + LogicSelectionCopyRequest selection; + selection.cells.push_back({source, 2}); + const LogicClipboardCopyResult copied = fixture.editor.copySelection( + fixture.logicId, selection); + require( + copied.copy.succeeded + && copied.fragment.mode == LogicClipboardMode::GridObjects + && copied.fragment.cells.size() == 1U + && copied.fragment.cells.front().kind == LadderCellKind::Wire + && copied.fragment.rowSpan == 1 + && copied.fragment.columnSpan == 1, + "a selected wire must become a one-cell grid fragment"); + + fixture.editor.clearHistory(); + const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard( + fixture.logicId, copied.fragment, {target, 5, false, false}); + require( + pasted.edit.succeeded + && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 2U + && fixture.editor.findCell(fixture.logicId, target, 5)->kind + == LadderCellKind::Wire, + "pasting one wire must change one target cell without adding a row"); + require( + fixture.editor.undo().succeeded + && fixture.editor.findCell(fixture.logicId, target, 5)->kind + == LadderCellKind::Gap, + "one undo must restore the complete single-wire paste"); } -void testSequentialConditionInsertionConsumesFollowingWire() +void testMixedAndSparseGridClipboardFragments() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10); - require(wire.succeeded, - "sequential wire replacement must start with a full-width wire"); - - LogicEditorResult inserted = service.replaceWireColumnWithCondition( - logic_id, rung_id, wire.id, 0, contact(0)); - require(inserted.succeeded, - "the first condition must replace the first full-wire cell"); - std::string selected_node_id = inserted.id; - for (int address = 1; address < 10; ++address) - { - inserted = service.insertConditionAfter( - logic_id, rung_id, selected_node_id, contact(address)); - require(inserted.succeeded, - "continuous condition insertion must consume the following wire cell"); - selected_node_id = inserted.id; - - const LadderRung *rung = service.findRung(logic_id, rung_id); - std::vector nodes; - collectConditionNodes(*rung->condition, &nodes); - require(rung != nullptr && rung->condition.has_value() - && conditionColumns(*rung->condition) == 10 - && nodes.size() == static_cast(address + 1) - && wireColumns(*rung->condition) == 9 - address, - "each continuous insertion must preserve width while consuming one wire cell"); - } - - const LadderRung *full = service.findRung(logic_id, rung_id); - require(full != nullptr && full->condition.has_value() - && conditionColumns(*full->condition) == 10 - && wireColumns(*full->condition) == 0, - "ten continuous insertions must replace the entire wire without expanding it"); - require(service.insertConditionAfter( - logic_id, rung_id, selected_node_id, contact(10)) - .error == LogicEditorError::InvalidOperation, - "the eleventh condition must still be rejected after all wire cells are consumed"); - require(conditionColumns(*service.findRung( - logic_id, rung_id)->condition) == 10, - "a rejected eleventh insertion must leave the full network unchanged"); - - require(service.undo().succeeded, - "a failed eleventh insertion must not displace the last successful undo step"); - const LadderRung *undone = service.findRung(logic_id, rung_id); - std::vector undone_nodes; - collectConditionNodes(*undone->condition, &undone_nodes); - require(undone_nodes.size() == 9U - && wireColumns(*undone->condition) == 1 - && conditionColumns(*undone->condition) == 10, - "undo must restore nine contacts followed by one wire cell"); - require(service.redo().succeeded, - "the final wire-consuming insertion must be redoable"); - const LadderRung *redone = service.findRung(logic_id, rung_id); - std::vector redone_nodes; - collectConditionNodes(*redone->condition, &redone_nodes); - require(redone_nodes.size() == 10U - && wireColumns(*redone->condition) == 0 - && conditionColumns(*redone->condition) == 10, - "redo must restore all ten contacts without wire cells"); + Fixture fixture; + const std::string source = fixture.addRung(); + const std::string target = fixture.addRung(); + const LogicEditorResult first = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 0, contact(70), true); + const LogicEditorResult second = fixture.editor.setConditionAtColumn( + fixture.logicId, source, 2, contact(71), true); + require( + first.succeeded && second.succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, source, 1, 1, true).succeeded, + "fixture must create a node-wire-node source fragment"); + LogicSelectionCopyRequest selection; + selection.cells = {{source, 2}, {source, 0}, {source, 1}}; + const LogicClipboardCopyResult copied = fixture.editor.copySelection( + fixture.logicId, selection); + require( + copied.copy.succeeded && copied.fragment.cells.size() == 3U + && copied.fragment.cells[0].relativeColumn == 0 + && copied.fragment.cells[1].relativeColumn == 1 + && copied.fragment.cells[2].relativeColumn == 2, + "mixed copied cells must be sorted by visual coordinates"); + + fixture.editor.clearHistory(); + const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard( + fixture.logicId, copied.fragment, {target, 4, false, false}); + const LadderCell *first_paste = fixture.editor.findCell( + fixture.logicId, target, 4); + const LadderCell *wire_paste = fixture.editor.findCell( + fixture.logicId, target, 5); + const LadderCell *second_paste = fixture.editor.findCell( + fixture.logicId, target, 6); + require( + pasted.edit.succeeded + && first_paste->kind == LadderCellKind::Node + && wire_paste->kind == LadderCellKind::Wire + && second_paste->kind == LadderCellKind::Node + && first_paste->node->id != first.id + && second_paste->node->id != second.id, + "mixed paste must preserve cell order and allocate new node IDs"); + require( + fixture.editor.undo().succeeded + && fixture.editor.findCell(fixture.logicId, target, 4)->kind + == LadderCellKind::Gap + && fixture.editor.findCell(fixture.logicId, target, 5)->kind + == LadderCellKind::Gap + && fixture.editor.findCell(fixture.logicId, target, 6)->kind + == LadderCellKind::Gap, + "one undo must remove every object in a mixed paste"); + + Fixture sparse; + const std::string sparse_source = sparse.addRung(); + const std::string sparse_target = sparse.addRung(); + require( + sparse.editor.setHorizontalWireRange( + sparse.logicId, sparse_source, 0, 0, true).succeeded + && sparse.editor.setConditionAtColumn( + sparse.logicId, sparse_source, 2, contact(72), true).succeeded + && sparse.editor.setHorizontalWireRange( + sparse.logicId, sparse_target, 5, 5, true).succeeded, + "fixture must create a sparse source and occupied transparent hole"); + LogicSelectionCopyRequest sparse_selection; + sparse_selection.cells = {{sparse_source, 0}, {sparse_source, 2}}; + const LogicClipboardCopyResult sparse_copy = sparse.editor.copySelection( + sparse.logicId, sparse_selection); + require( + sparse.editor.pasteClipboard( + sparse.logicId, + sparse_copy.fragment, + {sparse_target, 4, false, false}).edit.succeeded + && sparse.editor.findCell(sparse.logicId, sparse_target, 4)->kind + == LadderCellKind::Wire + && sparse.editor.findCell(sparse.logicId, sparse_target, 5)->kind + == LadderCellKind::Wire + && sparse.editor.findCell(sparse.logicId, sparse_target, 6)->kind + == LadderCellKind::Node, + "an unselected hole must stay transparent and preserve target content"); } -void testLogicLifecycleAndOrdering() +void testGridClipboardFailuresAreAtomic() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string first_id = service.ensureDefaultLogic().id; - const LogicEditorResult second = service.addLogic("Safety logic"); - const LogicEditorResult third = service.addLogic("Alarm logic"); - require(second.succeeded && third.succeeded, - "multiple control logic modules must be creatable"); - require(service.renameLogic(second.id, "Interlock logic").succeeded, - "control logic modules must be renamable by stable id"); - require(service.renameLogic(third.id, "Interlock logic").error - == LogicEditorError::DuplicateName, - "control logic names must remain unique"); - require(service.moveLogic(third.id, -1).succeeded - && project_service.project().controlLogics.at(1).id == third.id, - "logic scan order must follow editable vector order"); - require(service.setLogicEnabled(second.id, false).succeeded - && !service.findLogic(second.id)->enabled, - "a control logic module must support explicit disable and enable"); - require(service.setLogicEnabled(second.id, true).succeeded - && service.findLogic(second.id)->enabled, - "a disabled control logic module must be re-enableable"); - require(service.removeLogic(third.id).succeeded, - "a non-final control logic module must be deletable"); - require(service.removeLogic(second.id).succeeded, - "logic deletion must preserve the remaining module"); - require(service.removeLogic(first_id).error - == LogicEditorError::LastLogicRequired, - "the project must retain at least one control logic module"); + Fixture fixture; + const std::string source = fixture.addRung(); + const std::string target = fixture.addRung(); + const std::string last = fixture.addRung(); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, source, 0, 1, true).succeeded + && fixture.editor.setConditionAtColumn( + fixture.logicId, target, 4, contact(73), true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, target, 0, 0, true).succeeded, + "fixture must create clipboard failure targets"); + + LogicSelectionCopyRequest one_wire_selection; + one_wire_selection.cells = {{source, 0}}; + const LogicClipboardFragment one_wire = fixture.editor.copySelection( + fixture.logicId, one_wire_selection).fragment; + fixture.editor.clearHistory(); + require( + !fixture.editor.pasteClipboard( + fixture.logicId, one_wire, {target, 4, false, false}).edit.succeeded + && fixture.editor.findCell(fixture.logicId, target, 4)->kind + == LadderCellKind::Node + && !fixture.editor.canUndo(), + "wire paste onto a node must fail without model or history changes"); + + LogicSelectionCopyRequest two_wire_selection; + two_wire_selection.cells = {{source, 0}, {source, 1}}; + const LogicClipboardFragment two_wires = fixture.editor.copySelection( + fixture.logicId, two_wire_selection).fragment; + require( + !fixture.editor.pasteClipboard( + fixture.logicId, two_wires, {target, 9, false, false}).edit.succeeded + && fixture.editor.findCell(fixture.logicId, target, 9)->kind + == LadderCellKind::Gap + && !fixture.editor.canUndo(), + "a fragment crossing the tenth condition column must fail atomically"); + + LogicSelectionCopyRequest cross_row_selection; + cross_row_selection.cells = {{target, 0}, {last, 0}}; + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, last, 0, 0, true).succeeded, + "fixture must complete a two-row source fragment"); + const LogicClipboardFragment cross_rows = fixture.editor.copySelection( + fixture.logicId, cross_row_selection).fragment; + fixture.editor.clearHistory(); + require( + !fixture.editor.pasteClipboard( + fixture.logicId, cross_rows, {last, 2, false, false}).edit.succeeded + && fixture.editor.findCell(fixture.logicId, last, 2)->kind + == LadderCellKind::Gap + && !fixture.editor.canUndo(), + "cross-row paste without enough target rows must fail atomically"); } -void testEdgeNodesAndRungComments() +void testOutputAndVerticalClipboardRules() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); + Fixture fixture; + const std::string first = fixture.addRung(); + const std::string second = fixture.addRung(); + const std::string third = fixture.addRung(); + const std::string fourth = fixture.addRung(); + require( + fixture.editor.setOutput( + fixture.logicId, + first, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 80}, CoilMode::Normal}, + true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + second, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 81}, CoilMode::Set}, + true).succeeded, + "fixture must create source and target outputs"); + LogicSelectionCopyRequest output_selection; + output_selection.outputRungIds = {first}; + const LogicClipboardFragment output = fixture.editor.copySelection( + fixture.logicId, output_selection).fragment; + require( + !fixture.editor.pasteClipboard( + fixture.logicId, output, {second, 0, false, false}).edit.succeeded, + "a copied output must reject a condition-grid target"); + const std::string old_output_id = fixture.editor.findRung( + fixture.logicId, second)->output->id; + fixture.editor.clearHistory(); + const LogicClipboardPasteResult output_paste = fixture.editor.pasteClipboard( + fixture.logicId, + output, + {second, ProjectLimits::kMaximumConditionColumns, true, false}); + require( + output_paste.edit.succeeded + && fixture.editor.findRung(fixture.logicId, second)->output->id + != old_output_id + && std::get( + fixture.editor.findRung(fixture.logicId, second)->output->config) + .address.index() == 80, + "an explicitly selected single output may replace a target output"); + require( + fixture.editor.undo().succeeded + && fixture.editor.findRung(fixture.logicId, second)->output->id + == old_output_id, + "one undo must restore the replaced output"); + + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, first, 8, 8, true).succeeded, + "fixture must add a condition-grid object beside the copied output"); + LogicSelectionCopyRequest mixed_output_selection; + mixed_output_selection.cells = {{first, 8}}; + mixed_output_selection.outputRungIds = {first}; + const LogicClipboardFragment mixed_output = fixture.editor.copySelection( + fixture.logicId, mixed_output_selection).fragment; + fixture.editor.clearHistory(); + require( + !fixture.editor.pasteClipboard( + fixture.logicId, + mixed_output, + {second, 8, false, false}).edit.succeeded + && fixture.editor.findCell(fixture.logicId, second, 8)->kind + == LadderCellKind::Gap + && fixture.editor.findRung(fixture.logicId, second)->output->id + == old_output_id + && !fixture.editor.canUndo(), + "a mixed fragment must not silently replace an occupied output slot"); + + require( + fixture.editor.setVerticalConnection( + fixture.logicId, + first, + second, + ProjectLimits::kMaximumConditionColumns, + true).succeeded, + "fixture must create an output-side vertical edge"); + LogicSelectionCopyRequest output_edge_selection; + output_edge_selection.outputRungIds = {first}; + output_edge_selection.verticalConnectionIds = { + connectionAt( + *fixture.editor.findLogic(fixture.logicId), + first, + second, + ProjectLimits::kMaximumConditionColumns)->id}; + const LogicClipboardFragment output_edge = fixture.editor.copySelection( + fixture.logicId, output_edge_selection).fragment; + fixture.editor.clearHistory(); + require( + fixture.editor.pasteClipboard( + fixture.logicId, + output_edge, + {third, ProjectLimits::kMaximumConditionColumns, true, false}) + .edit.succeeded + && fixture.editor.findRung(fixture.logicId, third) + ->output.has_value() + && connectionAt( + *fixture.editor.findLogic(fixture.logicId), + third, + fourth, + ProjectLimits::kMaximumConditionColumns) != nullptr, + "an output plus its right-side vertical edge must paste from the output anchor"); + require( + fixture.editor.undo().succeeded, + "one undo must remove the mixed output-edge paste"); + + const LogicEditorResult vertical = fixture.editor.setVerticalConnection( + fixture.logicId, first, second, 2, true); + require(vertical.succeeded, "fixture must create a copyable vertical edge"); + LogicSelectionCopyRequest vertical_selection; + vertical_selection.verticalConnectionIds = { + connectionAt( + *fixture.editor.findLogic(fixture.logicId), first, second, 2)->id}; + const LogicClipboardFragment edge = fixture.editor.copySelection( + fixture.logicId, vertical_selection).fragment; + fixture.editor.clearHistory(); + require( + fixture.editor.pasteClipboard( + fixture.logicId, edge, {second, 6, false, true}).edit.succeeded + && connectionAt( + *fixture.editor.findLogic(fixture.logicId), second, third, 6) + != nullptr, + "a vertical edge must paste onto a valid adjacent-row boundary"); + const std::size_t connection_count = fixture.editor.findLogic( + fixture.logicId)->verticalConnections.size(); + require( + fixture.editor.pasteClipboard( + fixture.logicId, edge, {second, 6, false, true}).edit.succeeded + && fixture.editor.findLogic(fixture.logicId) + ->verticalConnections.size() == connection_count, + "pasting an existing vertical edge must be idempotent"); + require( + !fixture.editor.pasteClipboard( + fixture.logicId, edge, {fourth, 6, false, true}).edit.succeeded, + "a vertical edge must not paste below the final row"); +} - const LogicEditorResult rising = service.appendCondition( - logic_id, - rung_id, - EdgeContactNodeConfig{ - RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising}); - require(rising.succeeded && rising.id == "edge-1", - "the editor must create rising edge nodes with a stable prefix"); - require(service.updateNodeConfig( - logic_id, - rising.id, - EdgeContactNodeConfig{ - RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling}) - .succeeded, - "the editor must apply edge mode and M address properties"); - require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded, - "the editor must update a network comment by stable rung id"); - require(!service.updateRungComment( - logic_id, rung_id, "第一行\n第二行").succeeded, - "the editor must reject multiline network comments"); - require(!service.updateRungComment( - logic_id, - rung_id, - std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a')) - .succeeded, - "the editor must reject oversized network comments"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - const LogicNode *edge = service.findNode(logic_id, rising.id); - require(rung != nullptr && rung->comment == "延时启动网络" - && edge != nullptr - && std::get(edge->config).mode - == EdgeMode::Falling, - "edge and rung comment updates must remain in the model"); +void testWholeRowClipboardInsertionAndLimit() +{ + Fixture fixture; + const std::string first = fixture.addRung(); + const std::string second = fixture.addRung(); + const std::string target = fixture.addRung(); + const LogicEditorResult source_node = fixture.editor.setConditionAtColumn( + fixture.logicId, first, 0, contact(90), true); + require( + source_node.succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, second, 0, 1, true).succeeded + && fixture.editor.updateRungComment( + fixture.logicId, first, "整行复制注释").succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, first, second, 2, true).succeeded, + "fixture must create two connected rows for whole-row copy"); + LogicSelectionCopyRequest selection; + selection.wholeRungIds = {second, first}; + const LogicClipboardCopyResult copied = fixture.editor.copySelection( + fixture.logicId, selection); + require( + copied.copy.succeeded + && copied.fragment.mode == LogicClipboardMode::WholeRows + && copied.fragment.rows.size() == 2U + && copied.fragment.verticalConnections.size() == 1U, + "explicit row headers must copy complete consecutive rows and internal edges"); + + fixture.editor.clearHistory(); + const LogicClipboardPasteResult pasted = fixture.editor.pasteClipboard( + fixture.logicId, copied.fragment, {target, 0, false, false}); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require( + pasted.edit.succeeded && pasted.wholeRungIds.size() == 2U + && logic->rungs.size() == 5U + && logic->rungs[3].id == pasted.wholeRungIds[0] + && logic->rungs[4].id == pasted.wholeRungIds[1] + && logic->rungs[3].comment == "整行复制注释" + && logic->rungs[3].cells[0].node->id != source_node.id + && connectionAt( + *logic, + pasted.wholeRungIds[0], + pasted.wholeRungIds[1], + 2) != nullptr, + "whole rows must insert after the selected row with new identities"); + require( + fixture.editor.undo().succeeded + && fixture.editor.findLogic(fixture.logicId)->rungs.size() == 3U, + "one undo must remove every row in one whole-row paste"); + + ProjectLimitSettings limits = defaultProjectLimitSettings(); + limits.maximumRungsPerLogic = 2U; + TestSupport::InMemoryProjectStorage storage; + ProjectService projects(storage, limits); + LogicEditorService editor(projects); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string source = editor.addRung(logic_id).id; + const std::string destination = editor.addRung(logic_id).id; + LogicSelectionCopyRequest limit_selection; + limit_selection.wholeRungIds = {source}; + const LogicClipboardFragment row = editor.copySelection( + logic_id, limit_selection).fragment; + editor.clearHistory(); + require( + !editor.pasteClipboard( + logic_id, row, {destination, 0, false, false}).edit.succeeded + && editor.findLogic(logic_id)->rungs.size() == 2U + && !editor.canUndo(), + "whole-row paste at the row limit must leave no partial edit or history"); } -void testHistoryAndAtomicBatchDelete() +void testSyntaxCheckNormalizesUnusedWiresAsOneEdit() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string first_rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult first = service.appendCondition( - logic_id, first_rung_id, contact(0)); - const LogicEditorResult second = service.appendCondition( - logic_id, first_rung_id, contact(1)); - const LogicEditorResult second_rung = service.addRung(logic_id); - const LogicEditorResult third = service.appendCondition( - logic_id, second_rung.id, contact(2)); - require(first.succeeded && second.succeeded && second_rung.succeeded - && third.succeeded, - "nodes for history testing must be created"); - - service.clearHistory(); - require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded, - "batch node deletion must validate every id before changing the logic"); - require(service.findNode(logic_id, first.id) != nullptr - && service.findNode(logic_id, third.id) != nullptr - && !service.canUndo(), - "failed batch node deletion must be atomic and leave history unchanged"); - - require(service.removeNodes(logic_id, {first.id, third.id}).succeeded, - "valid nodes across multiple rungs must be deleted together"); - require(service.findNode(logic_id, first.id) == nullptr - && service.findNode(logic_id, third.id) == nullptr, - "all selected nodes must be removed by one batch operation"); - require(service.undo().succeeded - && service.findNode(logic_id, first.id) != nullptr - && service.findNode(logic_id, third.id) != nullptr, - "logic undo must restore a cross-rung batch deletion"); - require(service.redo().succeeded - && service.findNode(logic_id, first.id) == nullptr - && service.findNode(logic_id, third.id) == nullptr, - "logic redo must reapply a cross-rung batch deletion"); - - service.clearHistory(); - const ControlLogic *logic = service.findLogic(logic_id); - require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded - && !service.canUndo(), - "setting an unchanged logic state must not consume history"); - - service.clearHistory(); - for (int index = 1; index <= 101; ++index) - { - require(service.updateRungComment( - logic_id, first_rung_id, "comment-" + std::to_string(index)) - .succeeded, - "repeated valid rung edits must succeed"); - } - int undo_count = 0; - while (service.undo().succeeded) + Fixture fixture; + const std::string output_rung = fixture.addRung(); + const std::string dangling_branch = fixture.addRung(); + const std::string isolated_rung = fixture.addRung(); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, output_rung, 0, 9, true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + output_rung, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 100}, CoilMode::Normal}, + true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, dangling_branch, 2, 5, true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, + output_rung, + dangling_branch, + 3, + true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, isolated_rung, 7, 8, true).succeeded, + "fixture must contain one valid output and several unused line fragments"); + + fixture.editor.clearHistory(); + const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax( + fixture.logicId); + const ControlLogic *logic = fixture.editor.findLogic(fixture.logicId); + require( + checked.completed && checked.valid && checked.changed + && checked.removedWireCells == 6U + && checked.removedVerticalConnections == 1U + && logic->verticalConnections.empty(), + "syntax check must remove every unused horizontal and vertical line; wires=" + + std::to_string(checked.removedWireCells) + + ", verticals=" + + std::to_string(checked.removedVerticalConnections) + + ", valid=" + std::to_string(checked.valid) + + ", changed=" + std::to_string(checked.changed)); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - ++undo_count; + require( + fixture.editor.findCell( + fixture.logicId, output_rung, column)->kind + == LadderCellKind::Wire, + "syntax normalization must preserve the complete output path"); } - require(undo_count == static_cast(EditorHistory::kMaximumEntries), - "logic history must retain exactly the configured most recent steps"); - - require(service.redo().succeeded, - "logic redo must be available after an undo"); - require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded, - "a new logic edit must succeed after undo"); - require(!service.canRedo(), - "a new logic edit must clear the redo history"); - (void)second; + requireEmptyGrid(*fixture.editor.findRung( + fixture.logicId, dangling_branch)); + requireEmptyGrid(*fixture.editor.findRung( + fixture.logicId, isolated_rung)); + require( + fixture.editor.canUndo() && fixture.editor.undo().succeeded + && fixture.editor.findLogic(fixture.logicId) + ->verticalConnections.size() == 1U + && fixture.editor.findCell( + fixture.logicId, dangling_branch, 2)->kind + == LadderCellKind::Wire + && fixture.editor.findCell( + fixture.logicId, isolated_rung, 7)->kind + == LadderCellKind::Wire + && !fixture.editor.canUndo(), + "all syntax normalization changes must be restored by one undo"); } -void testBatchPasteNodesAndRung() +void testSyntaxCheckPreservesValidParallelPath() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult first = service.appendCondition( - logic_id, rung_id, contact(10)); - const LogicEditorResult second = service.appendCondition( - logic_id, rung_id, contact(11)); - require(first.succeeded && second.succeeded, - "nodes for paste testing must be created"); - - const LogicNode first_copy = *service.findNode(logic_id, first.id); - const LogicNode second_copy = *service.findNode(logic_id, second.id); - service.clearHistory(); - const LogicEditorResult pasted = service.pasteConditionNodes( - logic_id, rung_id, {first_copy, second_copy}); - require(pasted.succeeded - && service.findNode(logic_id, pasted.id) != nullptr - && service.findRung(logic_id, rung_id)->condition.has_value() - && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U, - "batch condition paste must append fresh nodes"); - require(pasted.id != first.id && pasted.id != second.id - && service.undo().succeeded - && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U, - "batch condition paste must use fresh ids and one undo step"); - require(service.areConditionNodesContiguous( - logic_id, rung_id, {first.id, second.id}), - "adjacent nodes in one series must be copyable as a range"); - - const std::string wire_rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3); - service.clearHistory(); - LogicConditionPasteTarget wire_target; - wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; - wire_target.expressionId = wire.id; - wire_target.column = 1; - const LogicEditorResult pasted_on_wire = service.pasteConditionNodes( - logic_id, wire_rung_id, {first_copy, second_copy}, wire_target); - const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id); - require(pasted_on_wire.succeeded && wire_rung != nullptr - && wire_rung->condition.has_value() - && conditionNodes(*wire_rung->condition) == 2 - && conditionColumns(*wire_rung->condition) == 3, - "condition paste must replace the selected wire cell and consume following wire cells"); - require(service.undo().succeeded - && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3, - "wire-targeted paste must be undone as one edit"); - - const std::string full_rung_id = makeEmptyRung(service, logic_id); - for (int address = 0; address < 9; ++address) + Fixture fixture; + const std::string upper = fixture.addRung(); + const std::string lower = fixture.addRung(); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, upper, 0, 9, true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + upper, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 101}, CoilMode::Normal}, + true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, upper, 5, 5, false).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, lower, 0, 5, true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, upper, lower, 0, true).succeeded + && fixture.editor.setVerticalConnection( + fixture.logicId, upper, lower, 6, true).succeeded, + "fixture must create a parallel path around one broken upper cell"); + + fixture.editor.clearHistory(); + const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax( + fixture.logicId); + require( + checked.completed && checked.valid + && checked.removedWireCells == 5U + && checked.removedVerticalConnections == 0U + && fixture.editor.findLogic(fixture.logicId) + ->verticalConnections.size() == 2U, + "syntax normalization must keep every line used by the valid parallel path"); + for (int column = 0; column <= 5; ++column) { - require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded, - "nine conditions must fit before an atomic paste failure test"); + require( + fixture.editor.findCell( + fixture.logicId, lower, column)->kind + == LadderCellKind::Wire, + "the lower bypass path must remain complete"); } - service.clearHistory(); - require(!service.pasteConditionNodes( - logic_id, full_rung_id, {first_copy, second_copy}).succeeded - && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9 - && !service.canUndo(), - "a multi-node paste that exceeds ten columns must roll back completely"); - - const LogicEditorResult third = service.appendCondition( - logic_id, rung_id, contact(12)); - require(third.succeeded - && !service.areConditionNodesContiguous( - logic_id, rung_id, {first.id, third.id}), - "non-adjacent nodes must not be copied as one condition range"); - const LogicEditorResult output = service.setOutput( - logic_id, - rung_id, - CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set}, - true); - require(output.succeeded, "the source rung must accept an output before copying"); - const LadderRung source = *service.findRung(logic_id, rung_id); - service.clearHistory(); - const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size(); - const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source); - require(pasted_rung.succeeded - && service.findLogic(logic_id)->rungs.size() - == rung_count_before_paste + 1U - && pasted_rung.id != source.id, - "whole rung paste must append a new network"); - const LadderRung *copy = service.findRung(logic_id, pasted_rung.id); - require(copy != nullptr && copy->condition.has_value() - && copy->condition->id != source.condition->id - && copy->output.has_value() && source.output.has_value() - && copy->output->id != source.output->id - && std::get(copy->output->config).address.index() == 20 - && std::get(copy->output->config).mode == CoilMode::Set, - "whole rung paste must regenerate ids and preserve output configuration"); + require( + fixture.editor.findLogic(fixture.logicId)->validateForRunning(), + "the normalized parallel network must remain runnable"); } -void testConditionPasteTargetsAndValidation() +void testSyntaxCheckKeepsBrokenOutputForErrorLocation() { - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - - LogicNode source; - source.id = "copied-condition"; - source.config = contact(90); - source.configured = true; - - const std::string empty_column_rung = makeEmptyRung(service, logic_id); - LogicConditionPasteTarget empty_column; - empty_column.kind = LogicConditionPasteTargetKind::EmptyColumn; - empty_column.column = 2; - require(service.pasteConditionNodes( - logic_id, empty_column_rung, {source}, empty_column).succeeded, - "condition paste must support an empty grid column"); - require(conditionColumns(*service.findRung( - logic_id, empty_column_rung)->condition) == 3, - "empty-column paste must preserve the requested column offset"); - - const std::string branch_rung = makeEmptyRung(service, logic_id); - const LogicEditorResult branch_source = service.appendCondition( - logic_id, branch_rung, contact(1)); - const LogicEditorResult branch_source_two = service.appendCondition( - logic_id, branch_rung, contact(2)); - const LogicEditorResult branch_source_three = service.appendCondition( - logic_id, branch_rung, contact(3)); - require(branch_source.succeeded && branch_source_two.succeeded - && branch_source_three.succeeded, - "branch paste setup must add a source range"); - const LogicEditorResult branch = service.addParallelBranch( - logic_id, branch_rung, - {branch_source.id, branch_source_two.id, branch_source_three.id}, - contact(4)); - require(branch.succeeded, "branch paste setup must create a parallel branch"); - const ConditionExpression &paste_lower = service.findRung( - logic_id, branch_rung)->condition->children.at(1); - require(paste_lower.kind == ConditionExpressionKind::Series - && paste_lower.children.back().kind == ConditionExpressionKind::Wire, - "branch paste setup must persist the short branch padding wire"); - LogicConditionPasteTarget branch_target; - branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; - branch_target.expressionId = paste_lower.children.back().id; - branch_target.column = 1; - require(service.pasteConditionNodes( - logic_id, branch_rung, {source}, branch_target).succeeded, - "condition paste must support a persisted wire cell in a branch"); - - const std::string after_node_rung = makeEmptyRung(service, logic_id); - const LogicEditorResult after_source = service.appendCondition( - logic_id, after_node_rung, contact(3)); - require(after_source.succeeded, "after-node paste setup must add a source node"); - LogicConditionPasteTarget after_target; - after_target.kind = LogicConditionPasteTargetKind::AfterNode; - after_target.expressionId = after_source.id; - require(service.pasteConditionNodes( - logic_id, after_node_rung, {source}, after_target).succeeded, - "condition paste must support insertion after a selected node"); - require(conditionNodes(*service.findRung( - logic_id, after_node_rung)->condition) == 2, - "after-node paste must add exactly one condition"); - - const std::string replace_wire_rung = makeEmptyRung(service, logic_id); - const LogicEditorResult wire = service.appendWire( - logic_id, replace_wire_rung, 2); - require(wire.succeeded, "replace-wire paste setup must add a wire"); - LogicConditionPasteTarget replace_wire; - replace_wire.kind = LogicConditionPasteTargetKind::ReplaceWire; - replace_wire.expressionId = wire.id; - require(service.pasteConditionNodes( - logic_id, replace_wire_rung, {source}, replace_wire).succeeded, - "condition paste must replace an entire wire expression"); - require(conditionNodes(*service.findRung( - logic_id, replace_wire_rung)->condition) == 1, - "whole-wire paste must create one condition node"); - - const std::string replace_cell_rung = makeEmptyRung(service, logic_id); - const LogicEditorResult cell_wire = service.appendWire( - logic_id, replace_cell_rung, 3); - require(cell_wire.succeeded, "replace-cell paste setup must add a wire"); - LogicConditionPasteTarget replace_cell; - replace_cell.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; - replace_cell.expressionId = cell_wire.id; - replace_cell.column = 1; - require(service.pasteConditionNodes( - logic_id, replace_cell_rung, {source}, replace_cell).succeeded, - "condition paste must replace one selected wire cell"); - require(conditionColumns(*service.findRung( - logic_id, replace_cell_rung)->condition) == 3, - "wire-cell paste must preserve the original network width"); - - require(!service.pasteConditionNodes(logic_id, after_node_rung, {}).succeeded, - "an empty condition clipboard must be rejected"); - LogicNode output = source; - output.config = CoilNodeConfig{RegisterAddress{RegisterArea::M, 91}, CoilMode::Normal}; - require(!service.pasteConditionNodes( - logic_id, after_node_rung, {output}).succeeded, - "an output instruction must not be pasted into the condition area"); - require(!service.pasteConditionNodes( - "missing-logic", after_node_rung, {source}).succeeded, - "condition paste must reject an unknown logic"); - require(!service.pasteConditionNodes( - logic_id, "missing-rung", {source}).succeeded, - "condition paste must reject an unknown rung"); - - const LadderRung *before_invalid = service.findRung( - logic_id, after_node_rung); - require(before_invalid != nullptr && before_invalid->condition.has_value(), - "invalid paste setup must retain its target network"); - const int node_count_before_invalid = conditionNodes(*before_invalid->condition); - LogicConditionPasteTarget invalid_target; - invalid_target.kind = LogicConditionPasteTargetKind::AfterNode; - invalid_target.expressionId = "missing-expression"; - service.clearHistory(); - require(!service.pasteConditionNodes( - logic_id, after_node_rung, {source}, invalid_target).succeeded - && conditionNodes(*service.findRung( - logic_id, after_node_rung)->condition) == node_count_before_invalid - && !service.canUndo(), - "an invalid paste target must roll back without recording history"); + Fixture fixture; + const std::string rung_id = fixture.addRung(); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, rung_id, 2, 9, true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 102}, CoilMode::Normal}, + true).succeeded, + "fixture must create an output connected only on its right side"); + + fixture.editor.clearHistory(); + const LogicSyntaxCheckResult checked = fixture.editor.checkSyntax( + fixture.logicId); + require( + checked.completed && !checked.valid && !checked.changed + && checked.location.has_value() + && checked.location->logicId == fixture.logicId + && checked.location->rungId == rung_id + && checked.location->network == 1 + && checked.location->row == 1 + && checked.location->column + == ProjectLimits::kMaximumLadderColumns + && checked.message.find("第 11 列") != std::string::npos + && checked.message.find("第 1 列起断开") != std::string::npos + && fixture.editor.findRung(fixture.logicId, rung_id) + ->output.has_value() + && fixture.editor.findCell( + fixture.logicId, rung_id, 2)->kind == LadderCellKind::Wire + && !fixture.editor.canUndo(), + "a broken output network must stay visible and report its output slot"); } -void testConfiguredLogicLimits() +void testDoubleCoilCheckRemainsIndependent() { - TestProjectStorage storage; - ProjectLimitSettings limits; - limits.maximumControlLogics = 1U; - limits.maximumRungsPerLogic = 0U; - ProjectService project_service(storage, limits); - LogicEditorService service(project_service); - - const LogicEditorResult initial = service.ensureDefaultLogic(); - require(initial.succeeded, - "the configured-limit fixture must create its one default logic"); - require(!service.addLogic("Second logic").succeeded, - "the logic editor must use the configured logic-group limit"); - require(!service.addRung(initial.id).succeeded, - "the logic editor must use the configured per-logic rung limit"); + Fixture fixture; + const std::string first = fixture.addRung(); + const std::string second = fixture.addRung(); + require( + fixture.editor.setHorizontalWireRange( + fixture.logicId, first, 0, 9, true).succeeded + && fixture.editor.setHorizontalWireRange( + fixture.logicId, second, 0, 9, true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + first, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 120}, CoilMode::Normal}, + true).succeeded + && fixture.editor.setOutput( + fixture.logicId, + second, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 120}, CoilMode::Set}, + true).succeeded, + "fixture must create two outputs for the same M address"); + + fixture.editor.clearHistory(); + const LogicSyntaxCheckResult syntax = fixture.editor.checkSyntax( + fixture.logicId); + const LogicSyntaxCheckResult double_coil = + fixture.editor.checkDoubleCoils(fixture.logicId); + require( + syntax.completed && syntax.valid && !syntax.changed + && double_coil.completed && !double_coil.valid + && double_coil.location.has_value() + && double_coil.location->rungId == second + && double_coil.location->row == 2 + && double_coil.location->column + == ProjectLimits::kMaximumLadderColumns + && double_coil.message.find("M120") != std::string::npos + && double_coil.message.find("第 1 行") != std::string::npos + && !fixture.editor.canUndo(), + "double coils must be reported only by the independent check"); } } // namespace @@ -1762,37 +1410,38 @@ int main() { try { - testLogicCommandParsing(); - testContinuousLogicCommands(); - testExistingNodeCommandReplacement(); - testPositionedLogicCommandsAndAtomicFailures(); - testEmptyLogicCreatesNetworksOnFirstEdit(); - testBatchRungDeleteIsAtomic(); - testAppendingWireMovesToNextNetworkAfterTenColumns(); - testAppendingWireReusesDeletedGapCells(); - testStructuredEditingAndNormalization(); - testRangeParallelInsertion(); - testParallelBranchGridInsertion(); - testStructuredWireEditing(); - testWireCellParallelSelectionUsesExactColumns(); - testWireCellParallelSelectionAcrossAdjacentWires(); - testBatchDeleteAllNodesInParallelBranch(); - testConditionColumnLimit(); - testUnconditionalOutputEditing(); - testExplicitGapEditing(); - testColumnTargetedConditionInsertion(); - testWireColumnReplacement(); - testSequentialConditionInsertionConsumesFollowingWire(); - testLogicLifecycleAndOrdering(); - testEdgeNodesAndRungComments(); - testHistoryAndAtomicBatchDelete(); - testBatchPasteNodesAndRung(); - testConditionPasteTargetsAndValidation(); - testConfiguredLogicLimits(); + testContinuousGridAndIndependentHorizontalWires(); + testIndependentVerticalConnectionsAndNetworkSplit(); + testInsertRowSplitsVerticalEdges(); + testInsertConditionMovesOnlyRelatedVerticalBoundaries(); + testDeleteRowMergesOnlyContinuousEdges(); + testParallelBranchCreatesConnectedVisualRow(); + testParallelBranchReusesExistingEdges(); + testNodeDeletionAndHistoryAreAtomic(); + testSelectionDeletionIsAtomic(); + testInvalidSelectionDeletionDoesNotMutateOrRecordHistory(); + testCommandInputMapsToGridCoordinates(); + testProjectRungLimitAppliesToBranchAndPaste(); + testConfiguredRowLimit(); + testFirstEditOnEmptyLogicIsAtomic(); + testCursorAdvanceAndOutputTransaction(); + testOutputAutomaticallyCompletesTrailingWires(); + testOutputAdvancesPastTheWholeNetworkGroup(); + testOutputLimitFailureLeavesNoPartialEdit(); + testSingleWireClipboardPasteAndUndo(); + testMixedAndSparseGridClipboardFragments(); + testGridClipboardFailuresAreAtomic(); + testOutputAndVerticalClipboardRules(); + testWholeRowClipboardInsertionAndLimit(); + testSyntaxCheckNormalizesUnusedWiresAsOneEdit(); + testSyntaxCheckPreservesValidParallelPath(); + testSyntaxCheckKeepsBrokenOutputForErrorLocation(); + testDoubleCoilCheckRemainsIndependent(); } catch (const std::exception &error) { - std::cerr << "logic editor service tests failed: " << error.what() << '\n'; + std::cerr << "logic editor service tests failed: " + << error.what() << '\n'; return 1; } std::cout << "logic editor service tests passed\n"; diff --git a/app/tests/offline_simulation_service_tests.cpp b/app/tests/offline_simulation_service_tests.cpp index db7c68b..1fc7b18 100644 --- a/app/tests/offline_simulation_service_tests.cpp +++ b/app/tests/offline_simulation_service_tests.cpp @@ -101,58 +101,21 @@ LadderRung rung(const std::string &id, LadderRung result; result.id = id; result.name = id; - std::vector series_children; - for (std::size_t index = 0; index < stages.size(); ++index) + result.cells.reserve(ProjectLimits::kMaximumConditionColumns); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) { - std::vector parallel_children; - for (const LogicNode &node : stages[index]) + LadderCell cell; + cell.id = id + "-cell-" + std::to_string(column); + cell.kind = LadderCellKind::Wire; + if (column < static_cast(stages.size()) + && !stages[static_cast(column)].empty()) { - parallel_children.push_back(ConditionExpression::fromNode(node)); - } - if (parallel_children.size() == 1U) - { - series_children.push_back(std::move(parallel_children.front())); - } - else - { - ConditionExpression parallel; - parallel.id = id + "-parallel-" + std::to_string(index); - parallel.kind = ConditionExpressionKind::Parallel; - parallel.children = std::move(parallel_children); - series_children.push_back(std::move(parallel)); - } - } - if (series_children.size() == 1U) - { - result.condition = std::move(series_children.front()); - } - else - { - ConditionExpression series; - series.id = id + "-series"; - series.kind = ConditionExpressionKind::Series; - series.children = std::move(series_children); - result.condition = std::move(series); - } - const int occupied_columns = static_cast(stages.size()); - if (occupied_columns < ProjectLimits::kMaximumConditionColumns) - { - ConditionExpression wire = ConditionExpression::fromWire( - id + "-output-wire", - ProjectLimits::kMaximumConditionColumns - occupied_columns); - if (result.condition->kind == ConditionExpressionKind::Series) - { - result.condition->children.push_back(std::move(wire)); - } - else - { - ConditionExpression series; - series.id = id + "-explicit-series"; - series.kind = ConditionExpressionKind::Series; - series.children.push_back(std::move(*result.condition)); - series.children.push_back(std::move(wire)); - result.condition = std::move(series); + cell.kind = LadderCellKind::Node; + cell.node = stages[static_cast(column)].front(); } + result.cells.push_back(std::move(cell)); } result.output = output; return result; @@ -191,58 +154,42 @@ std::int16_t readWord(RegisterRepository &repository, int address) return result.value; } -void testNestedSeriesParallelExpression() +void testParallelRowsAndColumnPropagation() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; - ConditionExpression nested_series; - nested_series.id = "nested-series"; - nested_series.kind = ConditionExpressionKind::Series; - nested_series.children = { - ConditionExpression::fromNode(contact("b", 1)), - ConditionExpression::fromNode(contact("c", 2))}; - ConditionExpression padded_a; - padded_a.id = "padded-a"; - padded_a.kind = ConditionExpressionKind::Series; - padded_a.children = { - ConditionExpression::fromNode(contact("a", 0)), - ConditionExpression::fromWire("a-branch-wire", 1)}; - ConditionExpression parallel; - parallel.id = "root-parallel"; - parallel.kind = ConditionExpressionKind::Parallel; - parallel.children = {std::move(padded_a), nested_series}; - ConditionExpression root; - root.id = "nested-output-series"; - root.kind = ConditionExpressionKind::Series; - root.children = { - std::move(parallel), - ConditionExpression::fromWire("nested-output-wire", 8)}; - LadderRung nested_rung; - nested_rung.id = "nested-rung"; - nested_rung.name = "nested-rung"; - nested_rung.condition = root; - nested_rung.output = coil("nested-output", 10); - const ControlLogic program = logic({nested_rung}); + LadderRung first = rung( + "first", {{contact("a", 0)}}, + coil("first-output", 10)); + LadderRung second = rung( + "second", {{contact("c", 2)}}, + coil("second-output", 11)); + second.output.reset(); + ControlLogic program = logic({first, second}); + program.verticalConnections = { + {"left-bridge", "first", "second", 0}, + {"right-bridge", "first", "second", 1}}; - writeBit(repository, 1, true); - writeBit(repository, 2, true); + writeBit(repository, 0, true); LogicTraceSnapshot trace; require(executor.executeScan({program}, repository, &trace).succeeded, - "nested expression scan must succeed"); - require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)"); - require(trace.expressionValues.at("nested-series") - && trace.expressionValues.at("root-parallel") - && trace.rungValues.at("nested-rung"), - "scan trace must expose active nested expression and rung values"); + "parallel row scan must succeed"); + require(readBit(repository, 10), "the first row must energize its output"); - writeBit(repository, 2, false); + writeBit(repository, 0, false); + writeBit(repository, 2, true); require(executor.executeScan({program}, repository, &trace).succeeded, - "nested false scan must succeed"); - require(!readBit(repository, 10), "incomplete B AND C branch must be false"); - writeBit(repository, 0, true); + "second parallel row scan must succeed"); + require(readBit(repository, 10), + "the lower branch must feed the shared output through the right edge"); + require(trace.verticalConnectionValues.at("left-bridge"), + "vertical connection trace must expose the boundary power"); + + program.verticalConnections.pop_back(); require(executor.executeScan({program}, repository, &trace).succeeded, - "alternate branch scan must succeed"); - require(readBit(repository, 10), "A branch must independently energize output"); + "the split network scan must succeed"); + require(!readBit(repository, 10), + "deleting the right edge must split the branch from the output"); } void testUnconditionalCoil() @@ -252,8 +199,15 @@ void testUnconditionalCoil() LadderRung unconditional; unconditional.id = "unconditional-rung"; unconditional.name = "unconditional-rung"; - unconditional.condition = ConditionExpression::fromWire( - "unconditional-wire", ProjectLimits::kMaximumConditionColumns); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + unconditional.cells.push_back({ + "unconditional-cell-" + std::to_string(column), + LadderCellKind::Wire, + std::nullopt}); + } unconditional.output = coil("unconditional-coil", 10); const ControlLogic program = logic({unconditional}); @@ -264,27 +218,52 @@ void testUnconditionalCoil() "a full-width wire network scan must succeed"); require(readBit(repository, 10), "a full-width wire network must energize its coil as a constant-true rung"); - require(trace.rungValues.at("unconditional-rung") - && trace.nodePowerValues.at("unconditional-coil"), + require(trace.rungValues.at("unconditional-rung"), "an unconditional rung must report energized power flow"); } +void testEnabledEmptyRowIsRejectedBeforeScanning() +{ + SoftwareLogicExecutor executor; + LadderRung empty; + empty.id = "empty-enabled-rung"; + empty.name = "Empty enabled rung"; + const ControlLogic program = logic({empty}); + const LogicScanResult validation = executor.validate({program}); + require(!validation.succeeded + && validation.error == LogicScanError::InvalidLogic, + "an enabled empty row must be rejected before fixed-grid scanning"); +} + void testWirePassThroughAndPowerTrace() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; - ConditionExpression root; - root.id = "wire-series"; - root.kind = ConditionExpressionKind::Series; - root.children = { - ConditionExpression::fromNode(contact("wire-input", 0)), - ConditionExpression::fromWire("wire-segment", 2), - ConditionExpression::fromNode(contact("wire-output", 1)), - ConditionExpression::fromWire("wire-output-padding", 6)}; LadderRung wired_rung; wired_rung.id = "wired-rung"; wired_rung.name = "wired-rung"; - wired_rung.condition = root; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + wired_rung.cells.push_back({ + "wire-cell-" + std::to_string(column), + LadderCellKind::Gap, + std::nullopt}); + } + wired_rung.cells[0].kind = LadderCellKind::Node; + wired_rung.cells[0].node = contact("wire-input", 0); + wired_rung.cells[1].kind = LadderCellKind::Wire; + wired_rung.cells[2].kind = LadderCellKind::Wire; + wired_rung.cells[3].kind = LadderCellKind::Node; + wired_rung.cells[3].node = contact("wire-output", 1); + for (int column = 4; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + wired_rung.cells[static_cast(column)].kind = + LadderCellKind::Wire; + } wired_rung.output = coil("wired-coil", 10); const ControlLogic program = logic({wired_rung}); @@ -294,17 +273,17 @@ void testWirePassThroughAndPowerTrace() "wire expression scan must succeed"); require(!readBit(repository, 10), "a horizontal wire must not bypass a false upstream series contact"); - require(trace.expressionValues.at("wire-segment") - && !trace.expressionInputValues.at("wire-segment") - && !trace.expressionPowerValues.at("wire-segment"), + require(trace.cellValues.at("wire-cell-1") + && !trace.cellInputPowerValues.at("wire-cell-1") + && !trace.cellPowerValues.at("wire-cell-1"), "a wire must remain logically true without showing false upstream power"); writeBit(repository, 0, true); require(executor.executeScan({program}, repository, &trace).succeeded, "powered wire expression scan must succeed"); require(readBit(repository, 10) - && trace.expressionInputValues.at("wire-segment") - && trace.expressionPowerValues.at("wire-segment"), + && trace.cellInputPowerValues.at("wire-cell-1") + && trace.cellPowerValues.at("wire-cell-1"), "a powered horizontal wire must pass current to the downstream contact"); } @@ -312,12 +291,20 @@ void testSeriesParallelContactsAndSequentialVisibility() { VirtualRegisterRepository repository; SoftwareLogicExecutor executor; - const ControlLogic program = logic({ - rung("rung-1", - {{contact("start", 0), contact("alternate", 1)}, - {contact("stop", 2, ContactMode::NormallyClosed)}}, - coil("run", 3)), - rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))}); + LadderRung primary = rung( + "rung-1", + {{contact("start", 0)}, + {contact("stop-primary", 2, ContactMode::NormallyClosed)}}, + coil("run", 3)); + LadderRung alternate = rung( + "rung-2", + {{contact("alternate", 1)}, + {contact("stop-alternate", 2, ContactMode::NormallyClosed)}}, + coil("downstream", 4)); + ControlLogic program = logic({primary, alternate}); + program.verticalConnections = { + {"parallel-left", "rung-1", "rung-2", 0}, + {"parallel-right", "rung-1", "rung-2", 2}}; writeBit(repository, 1, true); require(executor.executeScan({program}, repository).succeeded, @@ -333,6 +320,106 @@ void testSeriesParallelContactsAndSequentialVisibility() require(!readBit(repository, 4), "downstream normal coil must follow the new value"); } +void testMotorForwardReverseSelfHoldAndInterlockTruthTable() +{ + VirtualRegisterRepository repository; + SoftwareLogicExecutor executor; + LadderRung forward = rung( + "forward-rung", + {{contact("forward-start", 0)}, + {contact("forward-stop", 2, ContactMode::NormallyClosed)}, + {contact("forward-interlock", 11, ContactMode::NormallyClosed)}}, + coil("forward-coil", 10)); + LadderRung forward_hold = rung( + "forward-hold-rung", + {{contact("forward-hold", 10)}, + {contact("forward-hold-stop", 2, ContactMode::NormallyClosed)}, + {contact("forward-hold-interlock", 11, ContactMode::NormallyClosed)}}, + coil("unused-forward-branch-output", 20)); + forward_hold.output.reset(); + LadderRung reverse = rung( + "reverse-rung", + {{contact("reverse-start", 1)}, + {contact("reverse-stop", 2, ContactMode::NormallyClosed)}, + {contact("reverse-interlock", 10, ContactMode::NormallyClosed)}}, + coil("reverse-coil", 11)); + LadderRung reverse_hold = rung( + "reverse-hold-rung", + {{contact("reverse-hold", 11)}, + {contact("reverse-hold-stop", 2, ContactMode::NormallyClosed)}, + {contact("reverse-hold-interlock", 10, ContactMode::NormallyClosed)}}, + coil("unused-reverse-branch-output", 21)); + reverse_hold.output.reset(); + ControlLogic program = logic({ + forward, forward_hold, reverse, reverse_hold}); + program.id = "motor-control-logic"; + program.verticalConnections = { + {"forward-left", "forward-rung", "forward-hold-rung", 0}, + {"forward-right", "forward-rung", "forward-hold-rung", 10}, + {"reverse-left", "reverse-rung", "reverse-hold-rung", 0}, + {"reverse-right", "reverse-rung", "reverse-hold-rung", 10}}; + + LogicTraceSnapshot trace; + const auto scan = [&] + { + require(executor.executeScan({program}, repository, &trace).succeeded, + "the motor truth-table scan must succeed"); + }; + + scan(); + require(!readBit(repository, 10) && !readBit(repository, 11), + "both motor directions must be off in the stopped state"); + require( + trace.cellInputPowerValues.at("forward-rung-cell-0") + && !trace.cellPowerValues.at("forward-rung-cell-0") + && !trace.rungValues.at("forward-rung"), + "a false start contact must keep only its left terminal energized"); + + writeBit(repository, 0, true); + scan(); + require(readBit(repository, 10) && !readBit(repository, 11), + "the forward start input must energize only M10"); + require( + trace.cellInputPowerValues.at("forward-rung-cell-0") + && trace.cellPowerValues.at("forward-rung-cell-0") + && trace.verticalConnectionValues.at("forward-right") + && trace.nodeValues.at("forward-coil"), + "the forward trace must reach the output and connected branch edge"); + + writeBit(repository, 0, false); + scan(); + require(readBit(repository, 10) && !readBit(repository, 11), + "M10 must remain energized through the forward self-hold branch"); + + writeBit(repository, 1, true); + scan(); + require(readBit(repository, 10) && !readBit(repository, 11), + "the reverse start input must be blocked while forward is active"); + + writeBit(repository, 2, true); + scan(); + require(!readBit(repository, 10) && !readBit(repository, 11), + "the stop input must release both direction outputs"); + + writeBit(repository, 1, false); + writeBit(repository, 2, false); + scan(); + writeBit(repository, 1, true); + scan(); + require(!readBit(repository, 10) && readBit(repository, 11), + "the reverse start input must energize only M11 after stopping"); + + writeBit(repository, 1, false); + scan(); + require(!readBit(repository, 10) && readBit(repository, 11), + "M11 must remain energized through the reverse self-hold branch"); + + writeBit(repository, 0, true); + scan(); + require(!readBit(repository, 10) && readBit(repository, 11), + "the forward start input must be blocked while reverse is active"); +} + void testAllComparisons() { const std::array operations{ @@ -412,8 +499,10 @@ void testMultipleLogicScanOrderAndTraceIsolation() disabled_draft.id = "logic-draft"; disabled_draft.name = "Draft"; disabled_draft.enabled = false; - disabled_draft.rungs.push_back( - {"rung-1", "Draft", {}, std::nullopt, std::nullopt}); + LadderRung draft_rung; + draft_rung.id = "rung-1"; + draft_rung.name = "Draft"; + disabled_draft.rungs.push_back(std::move(draft_rung)); require(executor.validate({first, disabled_draft}).succeeded, "a disabled incomplete logic module must not block offline execution"); } @@ -614,11 +703,22 @@ void testHmiSimulationClosedLoop() indicator.bounds = {0, 40, 80, 30}; indicator.text = "run"; indicator.binding = RegisterAddress{RegisterArea::M, 2}; - const ControlLogic program = logic({ - rung("hold-rung", - {{contact("stop", 1, ContactMode::NormallyClosed)}, - {contact("start", 0), contact("feedback", 2)}}, - coil("run", 2))}); + LadderRung start_path = rung( + "hold-start", + {{contact("stop", 1, ContactMode::NormallyClosed)}, + {contact("start", 0)}}, + coil("run", 2)); + LadderRung feedback_path = rung( + "hold-feedback", + {{contact("feedback-padding", 4000)}, {contact("feedback", 2)}}, + coil("unused-output", 4000)); + feedback_path.cells[0].kind = LadderCellKind::Gap; + feedback_path.cells[0].node.reset(); + feedback_path.output.reset(); + ControlLogic program = logic({start_path, feedback_path}); + program.verticalConnections = { + {"hold-left", "hold-start", "hold-feedback", 1}, + {"hold-right", "hold-start", "hold-feedback", 2}}; require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded, "HMI start button press must write virtual M"); @@ -779,9 +879,11 @@ int main(int argc, char *argv[]) try { testSeriesParallelContactsAndSequentialVisibility(); - testNestedSeriesParallelExpression(); + testParallelRowsAndColumnPropagation(); testUnconditionalCoil(); + testEnabledEmptyRowIsRejectedBeforeScanning(); testWirePassThroughAndPowerTrace(); + testMotorForwardReverseSelfHoldAndInterlockTruthTable(); testAllComparisons(); testSetResetAndDisabledLogic(); testMultipleLogicScanOrderAndTraceIsolation(); diff --git a/app/tests/performance_tests.cpp b/app/tests/performance_tests.cpp index 280c98d..da50403 100644 --- a/app/tests/performance_tests.cpp +++ b/app/tests/performance_tests.cpp @@ -36,20 +36,24 @@ ControlLogic makeLogic(int index) LadderRung rung; rung.id = "rung-" + std::to_string(index); rung.name = rung.id; - ConditionExpression condition; - condition.id = "series-" + std::to_string(index); - condition.kind = ConditionExpressionKind::Series; - condition.children = { - ConditionExpression::fromNode( - contact("contact-" + std::to_string(index), index)), - ConditionExpression::fromWire( - "wire-" + std::to_string(index), 9)}; - rung.condition = std::move(condition); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + rung.id + "-cell-" + std::to_string(column), + column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, + column == 0 + ? std::optional{contact( + "contact-" + std::to_string(index), index)} + : std::nullopt}); + } rung.output = coil("coil-" + std::to_string(index), index + 100); return {"logic-" + std::to_string(index), "logic-" + std::to_string(index), {rung}, - true}; + true, + {}}; } class PerformanceTests final : public QObject diff --git a/app/tests/plc_runtime_tests.cpp b/app/tests/plc_runtime_tests.cpp index 5ce360e..dadd74f 100644 --- a/app/tests/plc_runtime_tests.cpp +++ b/app/tests/plc_runtime_tests.cpp @@ -11,6 +11,7 @@ #include "services/plc_discovery_gateway.h" #include "services/project_service.h" #include "services/runtime_mode_service.h" +#include "services/logic_editor_service.h" #include #include @@ -187,15 +188,20 @@ void testRuntimeRepositorySwitchingAndDisconnect() indicator.text = "Run"; indicator.binding = RegisterAddress{RegisterArea::M, 5}; page.controls.push_back(indicator); + project_service.editProject().initialHmiPageId = page.id; project_service.editProject().hmiPages.push_back(page); VirtualRegisterRepository virtual_repository; PlcRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); + LogicEditorService logic_editor_service(project_service); FakePlcGateway gateway; RuntimeModeService service( - project_service, simulation_service, online_monitor_service); + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); @@ -246,9 +252,13 @@ void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect() ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); + LogicEditorService logic_editor_service(project_service); FakePlcGateway gateway; RuntimeModeService service( - project_service, simulation_service, online_monitor_service); + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); diff --git a/app/tests/plc_runtime_tests.pro b/app/tests/plc_runtime_tests.pro index 82580c9..1a46c8b 100644 --- a/app/tests/plc_runtime_tests.pro +++ b/app/tests/plc_runtime_tests.pro @@ -10,6 +10,7 @@ SOURCES += \ plc_runtime_tests.cpp \ $$DOMAIN_ALL_SOURCES \ $$SERVICE_PROJECT_SOURCES \ + $$SERVICE_LOGIC_SOURCES \ $$SERVICE_OFFLINE_SOURCES \ $$SERVICE_RUNTIME_SOURCES \ $$INFRASTRUCTURE_PLC_SOURCES @@ -17,6 +18,7 @@ SOURCES += \ HEADERS += \ $$DOMAIN_ALL_HEADERS \ $$SERVICE_PROJECT_HEADERS \ + $$SERVICE_LOGIC_HEADERS \ $$SERVICE_OFFLINE_HEADERS \ $$SERVICE_RUNTIME_HEADERS \ $$INFRASTRUCTURE_PLC_HEADERS \ diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 3deb7c2..76f88a6 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -1,10 +1,8 @@ -#include "domain/project_storage.h" -#include "domain/hmi_model.h" +#include "domain/project_limits.h" #include "infrastructure/json_project_storage.h" -#include "support/test_support.h" -#include "services/register_comment_service.h" #include "services/project_service.h" -#include "domain/project_limits.h" +#include "services/register_comment_service.h" +#include "support/test_support.h" #include #include @@ -14,7 +12,6 @@ #include #include -#include #include #include @@ -22,181 +19,87 @@ namespace { using TestSupport::require; -Project makeExampleProject() +LogicNode contact(const std::string &id, int address) { - // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本 - HmiControl start_button; - start_button.id = "start-button"; - start_button.type = HmiControlType::Button; - start_button.bounds = {10, 20, 120, 48}; - start_button.text = "Start"; - start_button.binding = RegisterAddress{RegisterArea::M, 0}; - start_button.buttonOperation = HmiButtonOperation::SetOn; - start_button.properties.emplace("color", "green"); - start_button.properties.emplace(HmiAppearanceProperty::kTextColor, "#E53935"); - start_button.properties.emplace(HmiAppearanceProperty::kFontSize, "18"); - start_button.properties.emplace(HmiAppearanceProperty::kFontBold, "true"); - start_button.properties.emplace(HmiAppearanceProperty::kFontItalic, "false"); - - HmiControl running_indicator; - running_indicator.id = "running-indicator"; - running_indicator.type = HmiControlType::Indicator; - running_indicator.bounds = {150, 20, 64, 64}; - running_indicator.text = "Running"; - running_indicator.binding = RegisterAddress{RegisterArea::M, 1}; - running_indicator.properties.emplace("activeColor", "#24a148"); - - HmiControl temperature_display; - temperature_display.id = "temperature-display"; - temperature_display.type = HmiControlType::NumericDisplay; - temperature_display.bounds = {10, 90, 120, 40}; - temperature_display.text = "Temperature"; - temperature_display.binding = RegisterAddress{RegisterArea::D, 2}; - temperature_display.properties.emplace("format", "decimal"); + return { + id, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, address}, + ContactMode::NormallyOpen}, + true}; +} - HmiControl target_input; - target_input.id = "target-input"; - target_input.type = HmiControlType::NumericInput; - target_input.bounds = {150, 90, 120, 40}; - target_input.text = "Target"; - target_input.binding = RegisterAddress{RegisterArea::D, 3}; - target_input.properties.emplace("minimum", "-100"); +LogicNode edge(const std::string &id, int address) +{ + return { + id, + EdgeContactNodeConfig{ + RegisterAddress{RegisterArea::M, address}, + EdgeMode::Rising}, + true}; +} - HmiControl title_label; - title_label.id = "title-label"; - title_label.type = HmiControlType::Label; - title_label.bounds = {10, 145, 180, 32}; - title_label.text = "Production line"; +LogicNode coil(const std::string &id, int address) +{ + return { + id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, address}, + CoilMode::Normal}, + true}; +} - HmiControl settings_jump; - settings_jump.id = "settings-jump"; - settings_jump.type = HmiControlType::PageJump; - settings_jump.bounds = {210, 145, 120, 40}; - settings_jump.text = "Settings"; - settings_jump.pageJump = HmiPageJumpConfig{"settings-page"}; +LadderRung makeRung( + const std::string &id, + LogicNode condition, + LogicNode output) +{ + LadderRung rung; + rung.id = id; + rung.name = id; + rung.comment = id + " comment"; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + id + "-cell-" + std::to_string(column), + column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, + column == 0 ? std::optional{condition} + : std::nullopt}); + } + rung.output = std::move(output); + return rung; +} - HmiControl alarm_list; - alarm_list.id = "alarm-list"; - alarm_list.type = HmiControlType::AlarmList; - alarm_list.bounds = {10, 200, 360, 180}; - alarm_list.text = "Alarms"; +Project makeExampleProject() +{ + Project project; + project.metadata = {"example-project", "Example project", "2.0"}; HmiPage page; page.id = "main-page"; page.name = "Main"; - page.controls.push_back(start_button); - page.controls.push_back(running_indicator); - page.controls.push_back(temperature_display); - page.controls.push_back(target_input); - page.controls.push_back(title_label); - page.controls.push_back(settings_jump); - page.controls.push_back(alarm_list); - - HmiPage settings_page; - settings_page.id = "settings-page"; - settings_page.name = "Settings"; - - LogicNode contact; - contact.id = "start-contact"; - contact.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 0}, - ContactMode::NormallyOpen}; - - LogicNode compare; - compare.id = "temperature-check"; - compare.config = CompareNodeConfig{ - RegisterAddress{RegisterArea::D, 2}, - ComparisonOperator::GreaterThanOrEqual, - static_cast(100)}; - - LogicNode hold_contact; - hold_contact.id = "hold-contact"; - hold_contact.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - ContactMode::NormallyOpen}; - - LogicNode coil; - coil.id = "run-coil"; - coil.config = CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - CoilMode::Set}; + HmiControl button; + button.id = "start-button"; + button.type = HmiControlType::Button; + button.bounds = {10, 10, 100, 40}; + button.text = "Start"; + button.binding = RegisterAddress{RegisterArea::M, 0}; + button.buttonOperation = HmiButtonOperation::SetOn; + page.controls.push_back(button); + project.hmiPages.push_back(page); + project.initialHmiPageId = page.id; ControlLogic logic; - logic.id = "start-logic"; - logic.name = "Start logic"; - logic.enabled = false; - LadderRung rung; - rung.id = "rung-1"; - rung.name = "Network 1"; - rung.comment = "启动条件与温度检查"; - ConditionExpression parallel; - parallel.id = "parallel-start"; - parallel.kind = ConditionExpressionKind::Parallel; - parallel.children = { - ConditionExpression::fromNode(contact), - ConditionExpression::fromNode(hold_contact)}; - ConditionExpression series; - series.id = "series-root"; - series.kind = ConditionExpressionKind::Series; - series.children = { - std::move(parallel), - ConditionExpression::fromWire("start-wire", 2), - ConditionExpression::fromNode(compare), - ConditionExpression::fromWire("start-output-wire", 6)}; - rung.condition = std::move(series); - rung.output = coil; - logic.rungs.push_back(rung); - - LogicNode rising_edge; - rising_edge.id = "rising-edge"; - rising_edge.config = EdgeContactNodeConfig{ - RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising}; - LogicNode edge_output; - edge_output.id = "edge-output"; - edge_output.config = CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal}; - LadderRung edge_rung; - edge_rung.id = "edge-rung"; - edge_rung.name = "Edge network"; - edge_rung.comment = "上升沿输出"; - ConditionExpression edge_series; - edge_series.id = "edge-series"; - edge_series.kind = ConditionExpressionKind::Series; - edge_series.children = { - ConditionExpression::fromNode(rising_edge), - ConditionExpression::fromWire("edge-output-wire", 9)}; - edge_rung.condition = std::move(edge_series); - edge_rung.output = edge_output; - logic.rungs.push_back(edge_rung); - - const auto addDataRung = [&logic]( - const std::string &rung_id, - const std::string &input_id, - int input_address, - LogicNode output) - { - LogicNode input; - input.id = input_id; - input.config = ContactNodeConfig{ - RegisterAddress{RegisterArea::M, input_address}, - ContactMode::NormallyOpen}; - LadderRung data_rung; - data_rung.id = rung_id; - data_rung.name = rung_id; - ConditionExpression data_series; - data_series.id = rung_id + "-series"; - data_series.kind = ConditionExpressionKind::Series; - data_series.children = { - ConditionExpression::fromNode(input), - ConditionExpression::fromWire(rung_id + "-output-wire", 9)}; - data_rung.condition = std::move(data_series); - data_rung.output = std::move(output); - logic.rungs.push_back(std::move(data_rung)); - }; - addDataRung( - "move-rung", - "move-input", - 20, + logic.id = "logic-1"; + logic.name = "Main logic"; + logic.rungs.push_back(makeRung( + "rung-1", contact("start-contact", 0), coil("run-coil", 10))); + logic.rungs.push_back(makeRung( + "rung-2", edge("edge-contact", 1), coil("edge-coil", 11))); + logic.rungs.push_back(makeRung( + "rung-3", contact("move-contact", 2), LogicNode{ "move-output", MoveNodeConfig{ @@ -205,11 +108,9 @@ Project makeExampleProject() RegisterAddress{RegisterArea::D, 0}, 25}, RegisterAddress{RegisterArea::D, 20}}, - true}); - addDataRung( - "add-rung", - "add-input", - 21, + true})); + logic.rungs.push_back(makeRung( + "rung-4", contact("add-contact", 3), LogicNode{ "add-output", ArithmeticNodeConfig{ @@ -223,807 +124,262 @@ Project makeExampleProject() RegisterAddress{RegisterArea::D, 0}, 1}, RegisterAddress{RegisterArea::D, 21}}, - true}); - addDataRung( - "sub-rung", - "sub-input", - 22, - LogicNode{ - "sub-output", - ArithmeticNodeConfig{ - ArithmeticOperation::Subtract, - WordOperand{ - WordOperandKind::Register, - RegisterAddress{RegisterArea::D, 21}, - 0}, - WordOperand{ - WordOperandKind::Constant, - RegisterAddress{RegisterArea::D, 0}, - 1}, - RegisterAddress{RegisterArea::D, 22}}, - true}); - - Project project; - project.metadata = {"example-project", "Example project", "1.0"}; - project.hmiPages.push_back(page); - project.hmiPages.push_back(settings_page); - project.initialHmiPageId = page.id; - project.alarmDefinitions.push_back( - {"alarm-emergency", - RegisterAddress{RegisterArea::M, 10}, - AlarmCondition::MOn, - 0, - "Emergency stop"}); - project.alarmDefinitions.push_back( - {"alarm-temperature", - RegisterAddress{RegisterArea::D, 2}, - AlarmCondition::DHigh, - 80, - "Temperature high"}); - project.registerComments = { - {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, - {RegisterAddress{RegisterArea::D, 2}, "当前温度"}}; - project.controlLogics.push_back(logic); - ControlLogic draft_logic; - draft_logic.id = "draft-logic"; - draft_logic.name = "Draft logic"; - draft_logic.enabled = false; - draft_logic.rungs.push_back( - {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); - project.controlLogics.push_back(draft_logic); + true})); + logic.verticalConnections = { + {"vertical-left", "rung-1", "rung-2", 0}, + {"vertical-right", "rung-1", "rung-2", 1}}; + project.controlLogics.push_back(std::move(logic)); + project.registerComments.push_back({ + RegisterAddress{RegisterArea::M, 0}, "Start signal"}); return project; } -void writeText(const QString &path, const QByteArray &content) +void writeBytes(const QString &path, const QByteArray &bytes) { - // 直接写入故障样本文件,以验证加载失败时的保护行为 QFile file(path); require(file.open(QIODevice::WriteOnly), "test file must be writable"); - require(file.write(content) == content.size(), "test file must be written completely"); + require(file.write(bytes) == bytes.size(), + "test file must be written completely"); } QByteArray readBytes(const QString &path) { QFile file(path); - require(file.open(QIODevice::ReadOnly), "saved project must be readable"); + require(file.open(QIODevice::ReadOnly), "test file must be readable"); return file.readAll(); } +QJsonObject savedFixture( + JsonProjectStorage &storage, + const Project &project, + const QString &path) +{ + require(storage.save(project, path.toStdString()).succeeded, + "fixture project must save"); + return QJsonDocument::fromJson(readBytes(path)).object(); +} + void testEmptyProjectRoundTrip() { - // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); - JsonProjectStorage storage; ProjectService service(storage); require(service.createNewProject("Empty project").succeeded, "empty project creation must succeed"); + require(service.project().metadata.formatVersion == "2.0", + "new projects must use the strict grid format"); const QString path = directory.filePath("empty.json"); require(service.saveAs(path.toStdString()).succeeded, "empty project save must succeed"); - require(!service.isModified(), "saved project must not be marked modified"); - require(service.load(path.toStdString()).succeeded, "empty project load must succeed"); - require(service.project().metadata.name == "Empty project", - "empty project name must survive round trip"); - require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); - require(service.project().controlLogics.empty(), - "empty project must have no control logics"); - require(service.project().alarmDefinitions.empty(), - "empty project must have no alarm definitions"); -} - -void testUnconditionalOutputRoundTrip() -{ - QTemporaryDir directory; - require(directory.isValid(), "temporary directory must be valid"); - - JsonProjectStorage storage; - ProjectService service(storage); - require(service.createNewProject("Unconditional output").succeeded, - "unconditional output project creation must succeed"); - LadderRung rung; - rung.id = "unconditional-rung"; - rung.name = "Unconditional rung"; - LogicNode coil; - coil.id = "unconditional-coil"; - coil.config = CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; - rung.condition = ConditionExpression::fromWire( - "unconditional-wire", ProjectLimits::kMaximumConditionColumns); - rung.output = coil; - service.editProject().controlLogics.push_back( - ControlLogic{"logic-1", "Logic 1", {rung}, true}); - require(service.project().validateForRunning(), - "an explicit full-width wire project must be runnable before saving"); - - const QString path = directory.filePath("unconditional-output.json"); - require(service.saveAs(path.toStdString()).succeeded, - "an output-only project must save successfully"); - require(service.load(path.toStdString()).succeeded, - "an output-only project must load successfully"); - const LadderRung &loaded = service.project().controlLogics.front().rungs.front(); - require(loaded.condition.has_value() - && loaded.condition->kind == ConditionExpressionKind::Wire - && loaded.condition->wire->columnSpan - == ProjectLimits::kMaximumConditionColumns - && loaded.output.has_value() - && loaded.validateForRunning(), - "JSON round trip must preserve explicit unconditional wire semantics"); - - QJsonObject implicit_root = QJsonDocument::fromJson(readBytes(path)).object(); - QJsonArray logics = implicit_root.value(QStringLiteral("controlLogics")).toArray(); - QJsonObject logic = logics.at(0).toObject(); - QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); - QJsonObject implicit_rung = rungs.at(0).toObject(); - implicit_rung.insert(QStringLiteral("condition"), QJsonValue::Null); - rungs.replace(0, implicit_rung); - logic.insert(QStringLiteral("rungs"), rungs); - logics.replace(0, logic); - implicit_root.insert(QStringLiteral("controlLogics"), logics); - const QString implicit_path = directory.filePath("implicit-output.json"); - writeText( - implicit_path, - QJsonDocument(implicit_root).toJson(QJsonDocument::Indented)); - require(!service.load(implicit_path.toStdString()).succeeded, - "version 1.0 implicit output wiring must not be migrated on load"); -} - -void testGapRoundTrip() -{ - QTemporaryDir directory; - require(directory.isValid(), "temporary directory must be valid"); - - JsonProjectStorage storage; - ProjectService service(storage); - require(service.createNewProject("Gap draft").succeeded, - "gap draft project creation must succeed"); - LadderRung gap_rung; - gap_rung.id = "gap-rung"; - gap_rung.name = "Gap rung"; - gap_rung.condition = ConditionExpression::fromGap( - "gap-full-width", ProjectLimits::kMaximumConditionColumns); - gap_rung.output = LogicNode{ - "gap-output", - CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, - true}; - service.editProject().controlLogics.push_back( - ControlLogic{"gap-logic", "Gap logic", {gap_rung}, true}); - require(service.project().validate() && !service.project().validateForRunning(), - "a gap network must be saveable but not runnable"); - - const QString gap_path = directory.filePath("gap-project.json"); - require(service.saveAs(gap_path.toStdString()).succeeded, - "a gap draft must save successfully"); - require(readBytes(gap_path).contains("\"kind\": \"gap\""), - "version 1.0 JSON must persist gap expressions explicitly"); - require(service.load(gap_path.toStdString()).succeeded, - "a version 1.0 gap draft must load successfully"); - const LadderRung &loaded_gap = - service.project().controlLogics.front().rungs.front(); - require(service.project().metadata.formatVersion == "1.0" - && loaded_gap.condition.has_value() - && loaded_gap.condition->kind == ConditionExpressionKind::Gap - && loaded_gap.condition->gap->columnSpan - == ProjectLimits::kMaximumConditionColumns - && !loaded_gap.validateForRunning(), - "gap JSON round trip must preserve the disconnected runtime state"); + require(service.project().controlLogics.empty() + && service.project().hmiPages.empty(), + "round trip must not invent project content"); } -void testExampleProjectRoundTrip() +void testGridProjectRoundTrip() { - // 验证各层嵌套字段往返后保持不变且序列化结果稳定 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); - JsonProjectStorage storage; - ProjectService service(storage); - service.editProject() = makeExampleProject(); - - const QString first_path = directory.filePath("example.json"); - const QString second_path = directory.filePath("example-copy.json"); - const QString invalid_operation_path = directory.filePath("invalid-operation.json"); - const QString unsupported_hmi_type_path = directory.filePath( - "unsupported-hmi-type.json"); - const QString missing_initial_path = directory.filePath("missing-initial.json"); - const QString missing_data_type_path = directory.filePath("missing-data-type.json"); - const QString missing_target_path = directory.filePath("missing-target.json"); - const QString missing_alarms_path = directory.filePath("missing-alarms.json"); - const QString missing_register_comments_path = directory.filePath( - "missing-register-comments.json"); - const QString missing_rung_comment_path = directory.filePath( - "missing-rung-comment.json"); - const QString multiline_register_comment_path = directory.filePath( - "multiline-register-comment.json"); - const QString multiline_rung_comment_path = directory.filePath( - "multiline-rung-comment.json"); - require(service.saveAs(first_path.toStdString()).succeeded, - "example project save must succeed"); - const QByteArray saved_json = readBytes(first_path); - require(saved_json.contains("\"rungs\"") - && saved_json.contains("\"condition\"") - && saved_json.contains("\"children\"") - && saved_json.contains("\"output\""), - "saved project must use structured ladder expressions"); - require(!saved_json.contains("\"dataPoints\""), - "saved project must not contain the removed data point model"); - require(saved_json.contains("\"formatVersion\": \"1.0\"") - && saved_json.contains("\"dataType\": \"int16\"") - && saved_json.contains("\"buttonOperation\": \"setOn\"") - && saved_json.contains("\"initialHmiPageId\": \"main-page\"") - && saved_json.contains("\"targetPageId\": \"settings-page\"") - && saved_json.contains("\"alarmDefinitions\"") - && saved_json.contains("\"registerComments\"") - && saved_json.contains("\"text\": \"启动按钮\"") - && saved_json.contains("\"type\": \"edgeContact\"") - && saved_json.contains("\"type\": \"move\"") - && saved_json.contains("\"type\": \"arithmetic\"") - && saved_json.contains("\"operation\": \"add\"") - && saved_json.contains("\"operation\": \"subtract\"") - && saved_json.contains("\"comment\": \"启动条件与温度检查\"") - && saved_json.contains("\"type\": \"alarmList\""), - "version 1.0 projects must persist pages and alarm definitions"); - require(!saved_json.contains("\"stages\"") - && !saved_json.contains("\"branches\""), - "current project format must not contain the removed stage model"); - require(!saved_json.contains("\"position\"") - && !saved_json.contains("\"connections\""), - "saved ladder logic must not contain free-graph fields"); - require(service.load(first_path.toStdString()).succeeded, - "example project load must succeed"); - - const Project &project = service.project(); - require(project.metadata.id == "example-project", "project id must survive round trip"); - require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); - require(project.initialHmiPageId == "main-page", - "the initial HMI page id must survive round trip"); - require(project.hmiPages.front().controls.size() == 7, - "register, navigation and AlarmList controls must survive round trip"); - require(project.hmiPages.front().controls.front().binding->area() - == RegisterArea::M, - "HMI M binding must survive round trip"); - require(project.hmiPages.front().controls.front().buttonOperation - == HmiButtonOperation::SetOn, - "HMI button operation must survive round trip"); - require(project.hmiPages.front().controls.front().properties.at("color") == "green", - "HMI properties must survive round trip"); - require(project.hmiPages.front().controls.front().properties.at( - HmiAppearanceProperty::kTextColor) == "#E53935" - && project.hmiPages.front().controls.front().properties.at( - HmiAppearanceProperty::kFontSize) == "18" - && project.hmiPages.front().controls.front().properties.at( - HmiAppearanceProperty::kFontBold) == "true" - && project.hmiPages.front().controls.front().properties.at( - HmiAppearanceProperty::kFontItalic) == "false", - "HMI appearance properties must survive round trip"); - require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator, - "indicator control type must survive round trip"); - require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D, - "numeric display D binding must survive round trip"); - require(project.hmiPages.front().controls.at(3).bounds.x == 150, - "numeric input bounds must survive round trip"); - require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label - && project.hmiPages.front().controls.at(5).pageJump->targetPageId - == "settings-page", - "Label and PageJump typed data must survive round trip"); - require(project.hmiPages.front().controls.at(6).type - == HmiControlType::AlarmList, - "AlarmList control type must survive round trip"); - require(project.alarmDefinitions.size() == 2 - && project.alarmDefinitions.front().condition - == AlarmCondition::MOn - && project.alarmDefinitions.at(1).threshold == 80, - "M and D alarm definitions must survive round trip"); - require(project.registerComments.size() == 2 - && project.registerComments.front().address.area() == RegisterArea::M - && project.registerComments.front().text == "启动按钮" - && project.registerComments.at(1).address.area() == RegisterArea::D, - "M/D register comments must survive round trip"); - require(project.controlLogics.size() == 2, - "control logic count must survive round trip"); - require(!project.controlLogics.front().enabled, - "control logic enabled state must survive round trip"); - const LadderRung &rung = project.controlLogics.front().rungs.front(); - require(rung.comment == "启动条件与温度检查", - "rung comments must survive round trip"); - require(rung.condition.has_value() - && rung.condition->kind == ConditionExpressionKind::Series, - "series root expression must survive round trip"); - require(rung.condition->children.front().kind - == ConditionExpressionKind::Parallel - && rung.condition->children.front().children.size() == 2U, - "parallel expression branches must survive round trip"); - require(rung.condition->children.at(1).kind == ConditionExpressionKind::Wire - && rung.condition->children.at(1).wire->columnSpan == 2, - "horizontal wire span must survive JSON round trip"); - require(rung.output.has_value(), - "ladder output must survive round trip"); - - const LadderRung &edge_rung = project.controlLogics.front().rungs.at(1); - require(edge_rung.output.has_value() - && std::holds_alternative(edge_rung.output->config) - && std::get(edge_rung.output->config).address - == RegisterAddress{RegisterArea::M, 6}, - "edge network output must survive round trip"); + const QString path = directory.filePath("grid.json"); + const Project original = makeExampleProject(); + require(original.validate(), "the grid fixture must be structurally valid"); + require(storage.save(original, path.toStdString()).succeeded, + "the grid project must save"); + + const QByteArray json = readBytes(path); + require(json.contains("\"formatVersion\": \"2.0\"") + && json.contains("\"cells\"") + && json.contains("\"verticalConnections\"") + && json.contains("\"kind\": \"node\"") + && json.contains("\"kind\": \"wire\""), + "2.0 JSON must persist cells and independent vertical edges"); + require(!json.contains("\"condition\"") + && !json.contains("\"children\""), + "2.0 JSON must not serialize the removed expression tree"); + + const ProjectLoadResult loaded = storage.load(path.toStdString()); + require(loaded.succeeded, "the grid project must load"); + const ControlLogic &logic = loaded.project.controlLogics.front(); + require(logic.rungs.size() == 4U + && logic.rungs.front().cells.size() == 10U, + "all rows and ten fixed cells must survive round trip"); + require(logic.verticalConnections.size() == 2U + && logic.verticalConnections.front().columnBoundary == 0 + && logic.verticalConnections.back().columnBoundary == 1, + "independent vertical edge positions must survive round trip"); require(std::holds_alternative( - edge_rung.condition->children.front().node->config) - && std::get( - edge_rung.condition->children.front().node->config).mode - == EdgeMode::Rising, - "rising edge configuration must survive round trip"); - require(std::get( - project.controlLogics.front().rungs.at(2).output->config) - .source.constant == 25, - "MOVE operands must survive round trip"); - require(std::get( - project.controlLogics.front().rungs.at(3).output->config) - .operation == ArithmeticOperation::Add - && std::get( - project.controlLogics.front().rungs.at(4).output->config) - .operation == ArithmeticOperation::Subtract, - "ADD and SUB operations must survive round trip"); - - const auto &compare = std::get( - rung.condition->children.at(2).node->config); - require(compare.address.index() == 2 && compare.value == 100, - "comparison configuration must survive round trip"); - require(service.saveAs(second_path.toStdString()).succeeded, - "save as must succeed after load"); - require(readBytes(first_path) == readBytes(second_path), - "save and save as must produce stable JSON"); - - QByteArray invalid_operation = saved_json; - invalid_operation.replace( - "\"buttonOperation\": \"setOn\"", - "\"buttonOperation\": \"unsupported\""); - writeText(invalid_operation_path, invalid_operation); - require(!service.load(invalid_operation_path.toStdString()).succeeded, - "unsupported HMI button operations must be rejected"); - - QByteArray unsupported_hmi_type = saved_json; - unsupported_hmi_type.replace( - "\"type\": \"alarmList\"", - "\"type\": \"removedControl\""); - writeText(unsupported_hmi_type_path, unsupported_hmi_type); - const ProjectOperationResult unsupported_type_result = service.load( - unsupported_hmi_type_path.toStdString()); - require(!unsupported_type_result.succeeded - && unsupported_type_result.storageError - == ProjectStorageError::InvalidField, - "removed HMI control types must be rejected during strict loading"); - - QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object(); - missing_initial.remove(QStringLiteral("initialHmiPageId")); - writeText( - missing_initial_path, - QJsonDocument(missing_initial).toJson(QJsonDocument::Compact)); - ProjectOperationResult missing_result = service.load( - missing_initial_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::MissingField, - "the 1.0 schema must require initialHmiPageId without migration defaults"); - - QJsonObject missing_data_type = QJsonDocument::fromJson(saved_json).object(); - QJsonArray data_type_pages = missing_data_type.value( - QStringLiteral("hmiPages")).toArray(); - QJsonObject data_type_page = data_type_pages.at(0).toObject(); - QJsonArray data_type_controls = data_type_page.value( - QStringLiteral("controls")).toArray(); - QJsonObject numeric_display = data_type_controls.at(2).toObject(); - numeric_display.remove(QStringLiteral("dataType")); - data_type_controls.replace(2, numeric_display); - data_type_page.insert(QStringLiteral("controls"), data_type_controls); - data_type_pages.replace(0, data_type_page); - missing_data_type.insert(QStringLiteral("hmiPages"), data_type_pages); - writeText( - missing_data_type_path, - QJsonDocument(missing_data_type).toJson(QJsonDocument::Compact)); - missing_result = service.load(missing_data_type_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::MissingField, - "numeric controls must require dataType in strict 1.0 JSON"); - - QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object(); - missing_alarms.remove(QStringLiteral("alarmDefinitions")); - writeText( - missing_alarms_path, - QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact)); - missing_result = service.load(missing_alarms_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::MissingField, - "the 1.0 schema must require alarmDefinitions without migration defaults"); - - QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object(); - missing_register_comments.remove(QStringLiteral("registerComments")); - writeText( - missing_register_comments_path, - QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact)); - missing_result = service.load(missing_register_comments_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::MissingField, - "the 1.0 schema must require registerComments without migration defaults"); - - QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object(); - QJsonArray missing_comment_logics = missing_rung_comment.value( - QStringLiteral("controlLogics")).toArray(); - QJsonObject first_logic = missing_comment_logics.at(0).toObject(); - QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray(); - QJsonObject first_rung = first_rungs.at(0).toObject(); - first_rung.remove(QStringLiteral("comment")); - first_rungs.replace(0, first_rung); - first_logic.insert(QStringLiteral("rungs"), first_rungs); - missing_comment_logics.replace(0, first_logic); - missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics); - writeText( - missing_rung_comment_path, - QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact)); - missing_result = service.load(missing_rung_comment_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::MissingField, - "the 1.0 schema must require rung comments without migration defaults"); - - QJsonObject multiline_register_comment = QJsonDocument::fromJson( - saved_json).object(); - QJsonArray comments = multiline_register_comment.value( - QStringLiteral("registerComments")).toArray(); - QJsonObject first_comment = comments.at(0).toObject(); - first_comment.insert(QStringLiteral("text"), QStringLiteral("第一行\n第二行")); - comments.replace(0, first_comment); - multiline_register_comment.insert(QStringLiteral("registerComments"), comments); - writeText( - multiline_register_comment_path, - QJsonDocument(multiline_register_comment).toJson(QJsonDocument::Compact)); - missing_result = service.load(multiline_register_comment_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::InvalidProject, - "JSON loading must reject multiline register comments"); - - QJsonObject multiline_rung_comment = QJsonDocument::fromJson(saved_json).object(); - QJsonArray multiline_logics = multiline_rung_comment.value( - QStringLiteral("controlLogics")).toArray(); - first_logic = multiline_logics.at(0).toObject(); - first_rungs = first_logic.value(QStringLiteral("rungs")).toArray(); - first_rung = first_rungs.at(0).toObject(); - first_rung.insert(QStringLiteral("comment"), QStringLiteral("第一行\n第二行")); - first_rungs.replace(0, first_rung); - first_logic.insert(QStringLiteral("rungs"), first_rungs); - multiline_logics.replace(0, first_logic); - multiline_rung_comment.insert(QStringLiteral("controlLogics"), multiline_logics); - writeText( - multiline_rung_comment_path, - QJsonDocument(multiline_rung_comment).toJson(QJsonDocument::Compact)); - missing_result = service.load(multiline_rung_comment_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::InvalidProject, - "JSON loading must reject multiline rung comments"); - - QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); - QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); - QJsonObject main_page = pages.at(0).toObject(); - QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray(); - QJsonObject jump = controls.at(5).toObject(); - jump.remove(QStringLiteral("targetPageId")); - controls.replace(5, jump); - main_page.insert(QStringLiteral("controls"), controls); - pages.replace(0, main_page); - missing_target.insert(QStringLiteral("hmiPages"), pages); - writeText( - missing_target_path, - QJsonDocument(missing_target).toJson(QJsonDocument::Compact)); - missing_result = service.load(missing_target_path.toStdString()); - require(!missing_result.succeeded - && missing_result.storageError == ProjectStorageError::MissingField, - "the 1.0 schema must require PageJump targetPageId"); - + logic.rungs[1].cells[0].node->config) + && std::holds_alternative( + logic.rungs[2].output->config) + && std::holds_alternative( + logic.rungs[3].output->config), + "typed ladder instructions must survive grid round trip"); } -void testInvalidFiles() +void testStrictVersionAndRequiredFields() { - // 非法文件必须被拒绝,并且不得覆盖服务中当前工程 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); - JsonProjectStorage storage; - ProjectService service(storage); - service.editProject().metadata.name = "Current project"; - const QString invalid_json = directory.filePath("invalid-json.json"); - const QString missing_field = directory.filePath("missing-field.json"); - const QString unsupported_version = directory.filePath("unsupported-version.json"); - - writeText(invalid_json, "{"); - auto result = service.load(invalid_json.toStdString()); - require(!result.succeeded - && result.storageError == ProjectStorageError::InvalidJson, - "invalid JSON must be rejected"); - require(service.project().metadata.name == "Current project", - "invalid load must keep current project"); - - writeText(missing_field, R"({"formatVersion":"1.0"})"); - result = service.load(missing_field.toStdString()); - require(!result.succeeded - && result.storageError == ProjectStorageError::MissingField, - "missing fields must be rejected"); - - writeText(unsupported_version, R"({"formatVersion":"2.0"})"); - result = service.load(unsupported_version.toStdString()); - require(!result.succeeded - && result.storageError == ProjectStorageError::UnsupportedVersion, - "unsupported versions must be rejected"); + const QString fixture_path = directory.filePath("fixture.json"); + QJsonObject root = savedFixture( + storage, makeExampleProject(), fixture_path); + + root.insert(QStringLiteral("formatVersion"), QStringLiteral("1.0")); + const QString old_path = directory.filePath("old.json"); + writeBytes(old_path, QJsonDocument(root).toJson()); + require(storage.load(old_path.toStdString()).error + == ProjectStorageError::UnsupportedVersion, + "old 1.0 files must be rejected without migration code"); + + root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); + QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); + QJsonObject logic = logics.at(0).toObject(); + logic.remove(QStringLiteral("verticalConnections")); + logics.replace(0, logic); + root.insert(QStringLiteral("controlLogics"), logics); + const QString missing_path = directory.filePath("missing-connections.json"); + writeBytes(missing_path, QJsonDocument(root).toJson()); + require(storage.load(missing_path.toStdString()).error + == ProjectStorageError::MissingField, + "2.0 must require the vertical connection array explicitly"); } -void testQuantityFileLimits() +void testInvalidGridAndConnectionsAreRejected() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); - JsonProjectStorage storage; - ProjectService service(storage); - require(service.createNewProject("Quantity limits").succeeded, - "the quantity-limit fixture project must be created"); + const QString fixture_path = directory.filePath("fixture.json"); + const QJsonObject original = savedFixture( + storage, makeExampleProject(), fixture_path); - const QString oversized_path = directory.filePath("oversized.json"); - const QByteArray oversized( - static_cast(ProjectLimits::kMaximumProjectFileBytes + 1U), ' '); - writeText(oversized_path, oversized); - const ProjectLoadResult oversized_result = - storage.load(oversized_path.toStdString()); - require(!oversized_result.succeeded - && oversized_result.error == ProjectStorageError::InvalidJson, - "a JSON file over 16 MiB must be rejected before parsing"); - - const QString valid_path = directory.filePath("fixture.json"); - require(service.saveAs(valid_path.toStdString()).succeeded, - "the quantity-limit fixture must be saved"); - QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object(); - QJsonArray too_many_pages; - for (int index = 0; - index <= static_cast(ProjectLimits::kMaximumHmiPages); - ++index) + const auto firstLogic = [](QJsonObject *root) -> QJsonObject { - too_many_pages.append(QJsonObject{}); - } - root.insert(QStringLiteral("hmiPages"), too_many_pages); - const QString too_many_pages_path = directory.filePath("too-many-pages.json"); - writeText( - too_many_pages_path, - QJsonDocument(root).toJson(QJsonDocument::Compact)); - const ProjectLoadResult too_many_pages_result = - storage.load(too_many_pages_path.toStdString()); - require(!too_many_pages_result.succeeded - && too_many_pages_result.error == ProjectStorageError::InvalidField, - "a JSON page array over the configured limit must be rejected"); + return root->value(QStringLiteral("controlLogics")) + .toArray().at(0).toObject(); + }; + const auto replaceFirstLogic = []( + QJsonObject *root, const QJsonObject &logic) + { + QJsonArray logics = root->value( + QStringLiteral("controlLogics")).toArray(); + logics.replace(0, logic); + root->insert(QStringLiteral("controlLogics"), logics); + }; - service.editProject() = makeExampleProject(); - require(service.saveAs(valid_path.toStdString()).succeeded, - "the nested-expression fixture must be saved"); - root = QJsonDocument::fromJson(readBytes(valid_path)).object(); - QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); - QJsonObject logic = logics.at(0).toObject(); + QJsonObject short_grid = original; + QJsonObject logic = firstLogic(&short_grid); QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); QJsonObject rung = rungs.at(0).toObject(); - QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject(); - while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node")) - { - leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject(); - } - QJsonObject nested = leaf; - for (int depth = 1; - depth <= static_cast(ProjectLimits::kMaximumExpressionDepth) + 1; - ++depth) - { - QJsonObject sibling = leaf; - sibling.insert( - QStringLiteral("id"), - QStringLiteral("json-depth-sibling-") + QString::number(depth)); - QJsonArray children; - children.append(nested); - children.append(sibling); - QJsonObject parent; - parent.insert( - QStringLiteral("id"), - QStringLiteral("json-depth-") + QString::number(depth)); - parent.insert( - QStringLiteral("kind"), - depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series")); - parent.insert(QStringLiteral("children"), children); - nested = parent; - } - rung.insert(QStringLiteral("condition"), nested); - rungs[0] = rung; + QJsonArray cells = rung.value(QStringLiteral("cells")).toArray(); + cells.removeLast(); + rung.insert(QStringLiteral("cells"), cells); + rungs.replace(0, rung); logic.insert(QStringLiteral("rungs"), rungs); - logics[0] = logic; - root.insert(QStringLiteral("controlLogics"), logics); - const QString deep_path = directory.filePath("too-deep.json"); - writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact)); - const ProjectLoadResult deep_result = storage.load(deep_path.toStdString()); - require(!deep_result.succeeded - && deep_result.error == ProjectStorageError::InvalidField, - "a condition expression deeper than the configured limit must be rejected while parsing"); + replaceFirstLogic(&short_grid, logic); + const QString short_path = directory.filePath("short-grid.json"); + writeBytes(short_path, QJsonDocument(short_grid).toJson()); + require(storage.load(short_path.toStdString()).error + == ProjectStorageError::InvalidField, + "a row with fewer than ten cells must be rejected while parsing"); + + QJsonObject duplicate_edge = original; + logic = firstLogic(&duplicate_edge); + QJsonArray edges = logic.value( + QStringLiteral("verticalConnections")).toArray(); + QJsonObject duplicate = edges.at(0).toObject(); + duplicate.insert(QStringLiteral("id"), QStringLiteral("vertical-copy")); + edges.append(duplicate); + logic.insert(QStringLiteral("verticalConnections"), edges); + replaceFirstLogic(&duplicate_edge, logic); + const QString duplicate_path = directory.filePath("duplicate-edge.json"); + writeBytes(duplicate_path, QJsonDocument(duplicate_edge).toJson()); + require(storage.load(duplicate_path.toStdString()).error + == ProjectStorageError::InvalidProject, + "duplicate edges at one row boundary must be rejected"); + + QJsonObject non_adjacent = original; + logic = firstLogic(&non_adjacent); + edges = logic.value(QStringLiteral("verticalConnections")).toArray(); + QJsonObject invalid_edge = edges.at(0).toObject(); + invalid_edge.insert(QStringLiteral("lowerRungId"), + QStringLiteral("rung-3")); + edges.replace(0, invalid_edge); + logic.insert(QStringLiteral("verticalConnections"), edges); + replaceFirstLogic(&non_adjacent, logic); + const QString non_adjacent_path = directory.filePath("non-adjacent.json"); + writeBytes(non_adjacent_path, QJsonDocument(non_adjacent).toJson()); + require(storage.load(non_adjacent_path.toStdString()).error + == ProjectStorageError::InvalidProject, + "vertical edges between non-adjacent rows must be rejected"); } -void testHmiPageJsonBoundaries() +void testProjectServiceStateAndConfiguredLimits() { - // JSON 入口必须和页面领域校验使用同一组业务边界 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); - + const QString path = directory.filePath("project.json"); JsonProjectStorage storage; - ProjectService service(storage); - Project boundary_project = makeExampleProject(); - boundary_project.hmiPages.front().controls.clear(); - service.editProject() = std::move(boundary_project); - - const QString fixture_path = directory.filePath("hmi-page-boundary-fixture.json"); - require(service.saveAs(fixture_path.toStdString()).succeeded, - "the HMI boundary fixture must be saved"); - - const QJsonObject original_root = - QJsonDocument::fromJson(readBytes(fixture_path)).object(); - - const auto loadWithSize = [&](int width, int height, const QString &name) - { - QJsonObject root = original_root; - QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray(); - QJsonObject page = pages.at(0).toObject(); - page.insert(QStringLiteral("width"), width); - page.insert(QStringLiteral("height"), height); - pages.replace(0, page); - root.insert(QStringLiteral("hmiPages"), pages); - - const QString path = directory.filePath(name); - writeText(path, QJsonDocument(root).toJson(QJsonDocument::Compact)); - return storage.load(path.toStdString()); - }; - - require(!loadWithSize( - ProjectLimits::kMinimumHmiPageWidth - 1, - ProjectLimits::kDefaultHmiPageHeight, - QStringLiteral("width-below-minimum.json")) - .succeeded, - "JSON must reject an HMI page width below 320"); - require(!loadWithSize( - ProjectLimits::kMaximumHmiPageWidth + 1, - ProjectLimits::kDefaultHmiPageHeight, - QStringLiteral("width-above-maximum.json")) - .succeeded, - "JSON must reject an HMI page width above 1600"); - require(!loadWithSize( - ProjectLimits::kDefaultHmiPageWidth, - ProjectLimits::kMinimumHmiPageHeight - 1, - QStringLiteral("height-below-minimum.json")) - .succeeded, - "JSON must reject an HMI page height below 200"); - require(!loadWithSize( - ProjectLimits::kDefaultHmiPageWidth, - ProjectLimits::kMaximumHmiPageHeight + 1, - QStringLiteral("height-above-maximum.json")) - .succeeded, - "JSON must reject an HMI page height above 800"); - - const ProjectLoadResult minimum_result = loadWithSize( - ProjectLimits::kMinimumHmiPageWidth, - ProjectLimits::kMinimumHmiPageHeight, - QStringLiteral("minimum-size.json")); - require(minimum_result.succeeded - && minimum_result.project.hmiPages.front().width - == ProjectLimits::kMinimumHmiPageWidth - && minimum_result.project.hmiPages.front().height - == ProjectLimits::kMinimumHmiPageHeight, - "JSON must accept the minimum HMI page size 320x200"); - - const ProjectLoadResult maximum_result = loadWithSize( - ProjectLimits::kMaximumHmiPageWidth, - ProjectLimits::kMaximumHmiPageHeight, - QStringLiteral("maximum-size.json")); - require(maximum_result.succeeded - && maximum_result.project.hmiPages.front().width - == ProjectLimits::kMaximumHmiPageWidth - && maximum_result.project.hmiPages.front().height - == ProjectLimits::kMaximumHmiPageHeight, - "JSON must accept the maximum HMI page size 1600x800"); -} + require(storage.save(makeExampleProject(), path.toStdString()).succeeded, + "service fixture must save"); -void testServiceStateAndSaveErrors() -{ - // 保存路径和修改标记只在成功持久化后更新 - QTemporaryDir directory; - require(directory.isValid(), "temporary directory must be valid"); + ProjectLimitSettings limits = defaultProjectLimitSettings(); + limits.maximumRungsPerLogic = 2U; + JsonProjectStorage limited_storage(limits); + ProjectService limited_service(limited_storage, limits); + const std::string original_id = limited_service.project().metadata.id; + const ProjectOperationResult load = limited_service.load( + path.toStdString()); + require(!load.succeeded + && load.storageError == ProjectStorageError::InvalidField, + "configured row limits must be enforced during JSON parsing"); + require(limited_service.project().metadata.id == original_id + && !limited_service.hasCurrentFile(), + "a failed load must leave the current project untouched"); - JsonProjectStorage storage; ProjectService service(storage); require(service.save().error == ProjectServiceError::FilePathRequired, - "save without a current path must be rejected"); - require(service.createNewProject(" ").error - == ProjectServiceError::InvalidProjectName, - "blank project names must be rejected"); - - const QString path = directory.filePath("state.json"); - service.editProject().metadata.name = "State project"; + "save without a current path must fail explicitly"); + require(service.createNewProject("State project").succeeded, + "a valid project name must create a project"); require(service.saveAs(path.toStdString()).succeeded, - "state project save must succeed"); - service.editProject().metadata.name = "Changed project"; - require(service.isModified(), "editing the project must mark it modified"); - require(service.save().succeeded, "save must use the current file path"); - require(!service.isModified(), "successful save must clear modified state"); - - const QString failed_path = directory.filePath("missing/subdir/state.json"); - require(!service.saveAs(failed_path.toStdString()).succeeded, - "save to an unavailable path must fail"); - require(service.currentFilePath() == path.toStdString(), - "failed save as must keep the previous current path"); -} - -void testLowerConfiguredLimitRejectsProjectAtomically() -{ - QTemporaryDir directory; - require(directory.isValid(), "temporary directory must be valid"); - const QString path = directory.filePath("two-pages.json"); - - JsonProjectStorage default_storage; - require(default_storage.save(makeExampleProject(), path.toStdString()).succeeded, - "configured-limit fixture must be saved with default limits"); - - ProjectLimitSettings limits; - limits.maximumHmiPages = 1U; - JsonProjectStorage limited_storage(limits); - ProjectService service(limited_storage, limits); - const std::string original_id = service.project().metadata.id; - const ProjectOperationResult result = service.load(path.toStdString()); - require(!result.succeeded - && result.message.find("元素数量为 2,当前上限为 1") - != std::string::npos, - "a project above the configured limit must report actual and maximum counts"); - require(service.project().metadata.id == original_id - && !service.hasCurrentFile(), - "a rejected project must not replace or partially modify current state"); + "save-as must establish the current path"); + service.editProject().metadata.name = "Changed"; + require(service.isModified() && service.save().succeeded + && !service.isModified(), + "successful save must clear the modified state"); } void testRegisterCommentService() { JsonProjectStorage storage; - ProjectService project_service(storage); - RegisterCommentService service(project_service); - - require(service.setComment( - RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded, - "register comment service must create a trimmed D comment"); - require(service.setComment( - RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded, - "register comment service must create an M comment"); - require(service.comments().size() == 2U - && service.comments().front().address.area() == RegisterArea::M - && service.comments().front().address.index() == 2 - && service.comments().back().text == "目标温度", - "register comments must be sorted by area and address"); - require(service.setComment( - RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded - && service.comments().size() == 2U - && service.findComment(RegisterAddress{RegisterArea::M, 2})->text - == "新的启动信号", - "setting an existing address must update instead of duplicating it"); - require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded, - "blank register comments must be rejected by the service"); - require(service.setComment( - RegisterAddress{RegisterArea::M, 4}, - std::string(ProjectLimits::kMaximumRegisterCommentBytes, 'a')) + ProjectService projects(storage); + RegisterCommentService comments(projects); + require(comments.setComment( + RegisterAddress{RegisterArea::D, 3}, " Target value ") .succeeded, - "a register comment at the byte limit must be accepted"); - require(!service.setComment( - RegisterAddress{RegisterArea::M, 5}, - std::string(ProjectLimits::kMaximumRegisterCommentBytes + 1U, 'a')) - .succeeded, - "an oversized register comment must be rejected by the service"); - require(!service.setComment( - RegisterAddress{RegisterArea::M, 5}, "第一行\n第二行").succeeded, - "a multiline register comment must be rejected by the service"); - require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded - && service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr, - "register comments must be removable"); - require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded, - "removing a missing register comment must fail explicitly"); + "the comment service must trim and create comments"); + require(comments.setComment( + RegisterAddress{RegisterArea::M, 2}, "Start signal") + .succeeded, + "the comment service must support M comments"); + require(comments.comments().size() == 2U + && comments.comments().front().address.area() + == RegisterArea::M + && comments.comments().back().text == "Target value", + "comments must remain sorted and trimmed"); + require(!comments.setComment( + RegisterAddress{RegisterArea::M, 4}, " \t").succeeded, + "blank comments must be rejected"); + require(comments.removeComment( + RegisterAddress{RegisterArea::M, 2}).succeeded, + "comments must be removable"); } } // namespace @@ -1032,24 +388,19 @@ int main() { try { - // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 testEmptyProjectRoundTrip(); - testUnconditionalOutputRoundTrip(); - testGapRoundTrip(); - testExampleProjectRoundTrip(); + testGridProjectRoundTrip(); + testStrictVersionAndRequiredFields(); + testInvalidGridAndConnectionsAreRejected(); + testProjectServiceStateAndConfiguredLimits(); testRegisterCommentService(); - testInvalidFiles(); - testQuantityFileLimits(); - testHmiPageJsonBoundaries(); - testLowerConfiguredLimitRejectsProjectAtomically(); - testServiceStateAndSaveErrors(); } catch (const std::exception &error) { - std::cerr << "project management tests failed: " << error.what() << '\n'; + std::cerr << "project management tests failed: " + << error.what() << '\n'; return 1; } - std::cout << "project management tests passed\n"; return 0; } diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 9f98f93..80038f6 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -1,4 +1,5 @@ #include "services/runtime_mode_service.h" +#include "services/logic_editor_service.h" #include "services/offline_simulation_service.h" #include "services/project_service.h" #include "domain/active_register_repository.h" @@ -100,17 +101,17 @@ private: using TestSupport::require; -ConditionExpression conditionWithOutputWire( - LogicNode node, - const std::string &wire_id) +void setConditionPath(LadderRung *rung, LogicNode node) { - ConditionExpression series; - series.id = wire_id + "-series"; - series.kind = ConditionExpressionKind::Series; - series.children = { - ConditionExpression::fromNode(std::move(node)), - ConditionExpression::fromWire(wire_id, 9)}; - return series; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung->cells.push_back({ + rung->id + "-cell-" + std::to_string(column), + column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, + column == 0 ? std::optional{node} : std::nullopt}); + } } void testModeTransitions() @@ -123,9 +124,13 @@ void testModeTransitions() ActiveRegisterRepository active_repository(virtual_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(plc_repository); + LogicEditorService logic_editor_service(project_service); ReadyPlcGateway gateway; RuntimeModeService service( - project_service, simulation_service, online_monitor_service); + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); @@ -152,8 +157,7 @@ void testModeTransitions() LadderRung edge_rung; edge_rung.id = "poll-edge-rung"; edge_rung.name = "Poll edge"; - edge_rung.condition = conditionWithOutputWire( - edge, "poll-edge-output-wire"); + setConditionPath(&edge_rung, edge); edge_rung.output = edge_coil; logic.rungs.push_back(edge_rung); LogicNode comparison; @@ -167,8 +171,7 @@ void testModeTransitions() LadderRung comparison_rung; comparison_rung.id = "poll-comparison-rung"; comparison_rung.name = "Poll comparison"; - comparison_rung.condition = conditionWithOutputWire( - comparison, "poll-comparison-output-wire"); + setConditionPath(&comparison_rung, comparison); comparison_rung.output = comparison_coil; logic.rungs.push_back(comparison_rung); LogicNode move_input; @@ -186,8 +189,7 @@ void testModeTransitions() LadderRung move_rung; move_rung.id = "poll-move-rung"; move_rung.name = "Poll MOVE"; - move_rung.condition = conditionWithOutputWire( - move_input, "poll-move-output-wire"); + setConditionPath(&move_rung, move_input); move_rung.output = move_output; logic.rungs.push_back(move_rung); LogicNode add_input; @@ -210,8 +212,7 @@ void testModeTransitions() LadderRung add_rung; add_rung.id = "poll-add-rung"; add_rung.name = "Poll ADD"; - add_rung.condition = conditionWithOutputWire( - add_input, "poll-add-output-wire"); + setConditionPath(&add_rung, add_input); add_rung.output = add_output; logic.rungs.push_back(add_rung); project.controlLogics.push_back(logic); @@ -276,6 +277,100 @@ void testModeTransitions() "a completed PLC poll cycle must trigger one new local trace scan"); } +void testDisconnectedOutputBlocksOfflineAndOnlineRuntime() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + VirtualRegisterRepository virtual_repository; + VirtualRegisterRepository plc_repository; + ActiveRegisterRepository active_repository(virtual_repository); + OfflineSimulationService simulation_service(virtual_repository); + OnlineLogicMonitorService online_monitor_service(plc_repository); + LogicEditorService logic_editor_service(project_service); + ReadyPlcGateway gateway; + RuntimeModeService service( + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); + service.configurePlc( + gateway, active_repository, virtual_repository, plc_repository); + + ControlLogic logic; + logic.id = "broken-logic"; + logic.name = "断路逻辑"; + LadderRung rung; + rung.id = "broken-rung"; + rung.name = "行 1"; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + "broken-cell-" + std::to_string(column), + LadderCellKind::Gap, + std::nullopt}); + } + rung.output = LogicNode{ + "broken-output", + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal}, + true}; + logic.rungs.push_back(rung); + LadderRung unused; + unused.id = "unused-rung"; + unused.name = "行 2"; + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + unused.cells.push_back({ + "unused-cell-" + std::to_string(column), + column >= 3 && column <= 5 + ? LadderCellKind::Wire : LadderCellKind::Gap, + std::nullopt}); + } + logic.rungs.push_back(unused); + project_service.editProject().controlLogics.push_back(logic); + logic_editor_service.clearHistory(); + + const ModeTransitionResult offline = service.enterOfflineRunning(); + require( + !offline.succeeded + && offline.error == ModeTransitionError::ProjectNotReady + && offline.detail.find("断路逻辑") != std::string::npos + && offline.detail.find("第 11 列") != std::string::npos + && offline.detail.find("第 1 列") != std::string::npos + && service.mode() == ApplicationMode::Editing + && simulation_service.state() == SimulationState::Stopped + && service.lastSyntaxCheck().changed + && service.lastSyntaxCheck().removedWireCells == 3U + && logic_editor_service.findCell( + "broken-logic", "unused-rung", 3)->kind + == LadderCellKind::Gap + && logic_editor_service.canUndo(), + "runtime preflight must normalize unused lines before rejecting a broken output"); + + require(service.connectPlc( + {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, + "the online connectivity check requires a ready PLC cache"); + gateway.completeInitialRead(); + const ModeTransitionResult online = service.enterOnlineRunning(); + require( + !online.succeeded + && online.error == ModeTransitionError::ProjectNotReady + && online.detail == offline.detail + && service.mode() == ApplicationMode::Editing + && online_monitor_service.state() + == OnlineLogicMonitorState::Stopped + && !service.lastSyntaxCheck().changed + && logic_editor_service.undo().succeeded + && logic_editor_service.findCell( + "broken-logic", "unused-rung", 3)->kind + == LadderCellKind::Wire, + "the same disconnected output validation must block online runtime"); +} + } // namespace int main() @@ -284,6 +379,7 @@ int main() { // 运行模式只有这一组状态机边界测试 testModeTransitions(); + testDisconnectedOutputBlocksOfflineAndOnlineRuntime(); } catch (const std::exception &error) { diff --git a/app/tests/runtime_mode_service_tests.pro b/app/tests/runtime_mode_service_tests.pro index 0190d5c..9b98437 100644 --- a/app/tests/runtime_mode_service_tests.pro +++ b/app/tests/runtime_mode_service_tests.pro @@ -8,12 +8,14 @@ SOURCES += \ runtime_mode_service_tests.cpp \ $$DOMAIN_ALL_SOURCES \ $$SERVICE_PROJECT_SOURCES \ + $$SERVICE_LOGIC_SOURCES \ $$SERVICE_OFFLINE_SOURCES \ $$SERVICE_RUNTIME_SOURCES HEADERS += \ $$DOMAIN_ALL_HEADERS \ $$SERVICE_PROJECT_HEADERS \ + $$SERVICE_LOGIC_HEADERS \ $$SERVICE_OFFLINE_HEADERS \ $$SERVICE_RUNTIME_HEADERS \ $$TEST_SUPPORT_HEADERS diff --git a/app/tests/runtime_panel_controller_tests.cpp b/app/tests/runtime_panel_controller_tests.cpp index ee36326..b274857 100644 --- a/app/tests/runtime_panel_controller_tests.cpp +++ b/app/tests/runtime_panel_controller_tests.cpp @@ -17,9 +17,18 @@ #include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#include #include +#include #include #include #include @@ -38,7 +47,15 @@ ControlLogic makeAlwaysOnLogic() LadderRung rung; rung.id = "always-on-rung"; rung.name = "Always on"; - rung.condition = ConditionExpression::fromWire("always-on-wire", 10); + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + rung.cells.push_back({ + "always-on-cell-" + std::to_string(column), + LadderCellKind::Wire, + std::nullopt}); + } LogicNode output; output.id = "always-on-output"; @@ -56,11 +73,14 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() VirtualRegisterRepository virtual_repository; OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(virtual_repository); + LogicEditorService logic_editor_service(project_service); RuntimeModeService runtime_mode_service( - project_service, simulation_service, online_monitor_service); + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); HmiEditorService hmi_editor_service(project_service); HmiRuntimeService hmi_runtime_service(virtual_repository); - LogicEditorService logic_editor_service(project_service); HmiNavigationService hmi_navigation_service(project_service); AlarmService alarm_service(project_service, virtual_repository); RegisterMonitorService register_monitor_service(virtual_repository); @@ -128,6 +148,27 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() require(simulation_service.traceSnapshot() .forLogic(logic.id).rungValues.at("always-on-rung"), "the queued trace must contain an energized rung"); + QCoreApplication::processEvents(QEventLoop::AllEvents); + LogicEditorWidget *runtime_logic_view = controller.runtimeMonitorWidget() + ->findChild(QStringLiteral("runtimeLogicView")); + bool found_active_runtime_output = false; + require(runtime_logic_view != nullptr, + "runtime monitor must own the ladder trace view"); + for (QGraphicsItem *item : runtime_logic_view->scene()->items()) + { + if (item->data(0).toString() == QStringLiteral("output") + && item->data(1).toString() + == QStringLiteral("always-on-rung")) + { + found_active_runtime_output = item->data(3).toBool() + && item->data(4).toBool(); + } + } + require(found_active_runtime_output, + "the full executor snapshot must reach the runtime ladder exactly once"); + + require(simulation_service.executeOnce().succeeded, + "a second scan must queue the stale-trace regression event"); require(runtime_mode_service.enterEditing().succeeded, "offline simulation must return to editing before queued delivery"); @@ -142,6 +183,671 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() "a queued offline scan must not restore the trace after returning to editing"); } +void testLogicEditorGridSelectionAndDeletion() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService logic_editor_service(project_service); + const std::string logic_id = logic_editor_service.ensureDefaultLogic().id; + + LogicEditorWidget widget(logic_editor_service); + widget.setLogicId(logic_id); + require(widget.scene()->items().isEmpty(), + "an empty logic must not draw standalone power rails"); + require((widget.alignment() & Qt::AlignLeft) != 0 + && (widget.alignment() & Qt::AlignTop) != 0, + "the ladder canvas must start at its top-left origin"); + + const std::string rung_id = logic_editor_service.addRung(logic_id).id; + require(!rung_id.empty(), "the grid regression fixture must create a row"); + require(logic_editor_service.setHorizontalWireRange( + logic_id, rung_id, 4, 4, true).succeeded, + "the grid regression fixture must draw one horizontal cell"); + widget.reloadLogic(); + widget.resize(1200, 400); + widget.show(); + QCoreApplication::processEvents(QEventLoop::AllEvents); + + constexpr qreal left_bus = 60.0; + constexpr qreal cell_width = 96.0; + constexpr qreal output_width = 224.0; + constexpr qreal first_grid_top = 46.0; + constexpr qreal row_height = 78.0; + const auto clickScene = [&widget](const QPointF &position, + Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + const QPoint point = widget.mapFromScene(position); + QMouseEvent press( + QEvent::MouseButtonPress, + QPointF(point), + Qt::LeftButton, + Qt::LeftButton, + modifiers); + QApplication::sendEvent(widget.viewport(), &press); + QMouseEvent release( + QEvent::MouseButtonRelease, + QPointF(point), + Qt::LeftButton, + Qt::NoButton, + modifiers); + QApplication::sendEvent(widget.viewport(), &release); + }; + const auto dragScene = [&widget]( + const QPointF &from, + const QPointF &to, + Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + const QPoint from_point = widget.mapFromScene(from); + const QPoint to_point = widget.mapFromScene(to); + QMouseEvent press( + QEvent::MouseButtonPress, + QPointF(from_point), + Qt::LeftButton, + Qt::LeftButton, + modifiers); + QApplication::sendEvent(widget.viewport(), &press); + QMouseEvent move( + QEvent::MouseMove, + QPointF(to_point), + Qt::NoButton, + Qt::LeftButton, + modifiers); + QApplication::sendEvent(widget.viewport(), &move); + QMouseEvent release( + QEvent::MouseButtonRelease, + QPointF(to_point), + Qt::LeftButton, + Qt::NoButton, + modifiers); + QApplication::sendEvent(widget.viewport(), &release); + }; + clickScene(QPointF( + left_bus + 4.0 * cell_width + cell_width / 2.0, + first_grid_top + row_height / 2.0)); + + require(widget.selectedRungId() == rung_id, + "clicking a grid cell must select its row"); + QString delete_error; + QObject::connect( + &widget, + &LogicEditorWidget::editorError, + [&delete_error](const QString &message) { delete_error = message; }); + const LogicEditorResult deleted = widget.deleteSelected(); + require(deleted.succeeded, + "Delete on a selected horizontal cell must succeed: " + + delete_error.toStdString()); + const LadderRung *rung = logic_editor_service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->cells.size() == 10U + && rung->cells[4].kind == LadderCellKind::Gap, + "Delete on one horizontal cell must preserve the row and clear only that cell"); + + widget.clearSelection(); + require(widget.selectedRungId().empty(), + "clearing the selection must not expose the first row as selected"); + require(!widget.addHorizontalWire().succeeded, + "the horizontal-wire command must require an explicitly selected cell"); + + require(logic_editor_service.setHorizontalWireRange( + logic_id, rung_id, 3, 3, true).succeeded, + "fixture must restore a wire for precise hit testing"); + widget.reloadLogic(); + clickScene(QPointF( + left_bus + 3.0 * cell_width, + first_grid_top + row_height / 2.0)); + require(!widget.deleteSelected().succeeded, + "Delete on a column boundary must not delete an adjacent cell"); + require(logic_editor_service.findRung(logic_id, rung_id)->cells[3].kind + == LadderCellKind::Wire, + "a boundary selection must preserve the adjacent horizontal wire"); + + clickScene(QPointF( + left_bus + 10.0 * cell_width + output_width / 2.0, + first_grid_top + row_height / 2.0)); + require(!widget.deleteSelected().succeeded + && logic_editor_service.findLogic(logic_id)->rungs.size() == 1U, + "Delete on an empty output slot must never delete the whole row"); + + clickScene(QPointF( + left_bus + 2.0 * cell_width + cell_width / 2.0, + first_grid_top + row_height / 2.0)); + require(widget.addHorizontalWire().succeeded + && logic_editor_service.findRung(logic_id, rung_id)->cells[2].kind + == LadderCellKind::Wire, + "the horizontal-wire command must act on an explicitly selected cell"); + + const LogicEditorResult first_node = logic_editor_service.setConditionAtColumn( + logic_id, + rung_id, + 0, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 0}, + ContactMode::NormallyOpen}, + true); + const LogicEditorResult second_node = logic_editor_service.setConditionAtColumn( + logic_id, + rung_id, + 1, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 1}, + ContactMode::NormallyOpen}, + true); + require(first_node.succeeded && second_node.succeeded, + "fixture must create adjacent conditions for multi-selection"); + widget.reloadLogic(); + dragScene( + QPointF(left_bus + 5.0, first_grid_top + 5.0), + QPointF( + left_bus + 2.0 * cell_width - 5.0, + first_grid_top + row_height - 5.0)); + require(widget.selectedNodeIds().size() == 2U, + "mouse drag must select multiple conditions in the same row"); + widget.clearSelection(); + clickScene(QPointF( + left_bus + cell_width / 2.0, + first_grid_top + row_height / 2.0)); + dragScene( + QPointF(left_bus + cell_width + 5.0, first_grid_top + 5.0), + QPointF( + left_bus + 2.0 * cell_width - 5.0, + first_grid_top + row_height - 5.0), + Qt::ControlModifier); + require(widget.selectedNodeIds().size() == 2U, + "Ctrl-drag must append objects to the existing selection"); + require(widget.addParallelBranch(ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 2}, + ContactMode::NormallyOpen}).succeeded + && logic_editor_service.findLogic(logic_id)->rungs.size() == 2U, + "parallel insertion must use the explicitly selected condition range"); + const LogicEditorResult output = logic_editor_service.setOutput( + logic_id, + rung_id, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 3}, CoilMode::Normal}, + true); + require(output.succeeded, + "syntax-location fixture must create an output instruction"); + widget.reloadLogic(); + widget.focusSyntaxLocation( + rung_id, ProjectLimits::kMaximumLadderColumns); + require( + widget.selectedRungId() == rung_id + && widget.selectedNodeId() == output.id, + "a syntax error at column 11 must focus the output slot"); + widget.focusSyntaxLocation(rung_id, 1); + require( + widget.selectedRungId() == rung_id + && widget.selectedNodeId() == first_node.id, + "a syntax error in the condition area must focus its one-based column"); +} + +void testLadderLayoutAndDragDeletion() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string upper = editor.addRung(logic_id).id; + const std::string lower = editor.addRung(logic_id).id; + require( + editor.setConditionAtColumn( + logic_id, + upper, + 0, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 10}, + ContactMode::NormallyClosed}, + true).succeeded + && editor.setHorizontalWireRange( + logic_id, upper, 1, 1, true).succeeded + && editor.setOutput( + logic_id, + upper, + MoveNodeConfig{ + WordOperand{ + WordOperandKind::Register, + RegisterAddress{RegisterArea::D, 10}, + 0}, + RegisterAddress{RegisterArea::D, 20}}, + true).succeeded + && editor.setHorizontalWireRange( + logic_id, lower, 0, 1, true).succeeded + && editor.setVerticalConnection( + logic_id, upper, lower, 2, true).succeeded + && editor.updateRungComment( + logic_id, upper, "主网络注释").succeeded + && editor.updateRungComment( + logic_id, lower, "支路注释不应重复").succeeded, + "layout fixture must create a connected two-row network"); + editor.clearHistory(); + + LogicEditorWidget widget(editor); + widget.setLogicId(logic_id); + widget.resize(1320, 360); + widget.show(); + QCoreApplication::processEvents(QEventLoop::AllEvents); + + bool found_head_comment = false; + bool found_branch_comment = false; + for (QGraphicsItem *item : widget.scene()->items()) + { + auto *text = dynamic_cast(item); + if (text == nullptr) + { + continue; + } + found_head_comment = found_head_comment + || text->text() == QStringLiteral("主网络注释"); + found_branch_comment = found_branch_comment + || text->text() == QStringLiteral("支路注释不应重复"); + } + require(found_head_comment && !found_branch_comment, + "only the network-head comment must be rendered"); + + constexpr qreal left_bus = 60.0; + constexpr qreal cell_width = 96.0; + constexpr qreal right_bus = left_bus + 10.0 * cell_width + 224.0; + constexpr qreal first_grid_top = 46.0; + constexpr qreal second_grid_bottom = first_grid_top + 2.0 * 78.0; + QImage grid_image(1320, 360, QImage::Format_ARGB32_Premultiplied); + grid_image.fill(Qt::transparent); + { + QPainter painter(&grid_image); + widget.scene()->render( + &painter, + QRectF(0.0, 0.0, 1320.0, 360.0), + QRectF(0.0, 0.0, 1320.0, 360.0), + Qt::IgnoreAspectRatio); + } + const auto background_at = [&grid_image, first_grid_top](qreal x) + { + return grid_image.pixelColor( + qRound(x), qRound(first_grid_top + 68.0)); + }; + const QColor node_background = background_at( + left_bus + cell_width - 10.0); + const QColor wire_background = background_at( + left_bus + 2.0 * cell_width - 10.0); + const QColor gap_background = background_at( + left_bus + 3.0 * cell_width - 10.0); + const QColor output_background = background_at(right_bus - 10.0); + require( + node_background == QColor(QStringLiteral("#ffffff")) + && node_background == wire_background + && wire_background == gap_background + && gap_background == output_background, + "node, wire, gap, and output cells must share one grid background"); + + bool found_left_rail = false; + bool found_right_rail = false; + for (QGraphicsItem *item : widget.scene()->items()) + { + auto *line_item = dynamic_cast(item); + if (line_item == nullptr || line_item->data(0).isValid()) + { + continue; + } + const QLineF line = line_item->line(); + const bool exact_span = qFuzzyCompare( + line.y1() + 1.0, first_grid_top + 1.0) + && qFuzzyCompare( + line.y2() + 1.0, second_grid_bottom + 1.0); + found_left_rail = found_left_rail + || (exact_span && qFuzzyCompare( + line.x1() + 1.0, left_bus + 1.0)); + found_right_rail = found_right_rail + || (exact_span && qFuzzyCompare( + line.x1() + 1.0, right_bus + 1.0)); + } + require(found_left_rail && found_right_rail, + "both rails must share the exact first-to-last row span"); + + const QPoint from = widget.mapFromScene( + QPointF(left_bus + 4.0, first_grid_top + 4.0)); + const QPoint to = widget.mapFromScene( + QPointF(right_bus - 4.0, second_grid_bottom - 4.0)); + QMouseEvent press( + QEvent::MouseButtonPress, + QPointF(from), + Qt::LeftButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &press); + QMouseEvent move( + QEvent::MouseMove, + QPointF(to), + Qt::NoButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &move); + QMouseEvent release( + QEvent::MouseButtonRelease, + QPointF(to), + Qt::LeftButton, + Qt::NoButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &release); + require(widget.selectedNodeIds().size() == 2U, + "drag selection must include the condition and output instruction"); + const QList selected_scene_items = widget.scene()->items(); + require( + std::any_of( + selected_scene_items.cbegin(), + selected_scene_items.cend(), + [](QGraphicsItem *item) { return item->zValue() == 100.0; }), + "selected objects must be painted by the highest selection layer"); + + require(widget.deleteSelected().succeeded, + "Delete must submit the complete drag selection once"); + const LadderRung *upper_rung = editor.findRung(logic_id, upper); + const LadderRung *lower_rung = editor.findRung(logic_id, lower); + require( + upper_rung->cells[0].kind == LadderCellKind::Gap + && upper_rung->cells[1].kind == LadderCellKind::Gap + && !upper_rung->output.has_value() + && lower_rung->cells[0].kind == LadderCellKind::Gap + && lower_rung->cells[1].kind == LadderCellKind::Gap + && editor.findLogic(logic_id)->verticalConnections.empty(), + "drag deletion must clear cells, output, and vertical connection together"); + require(editor.undo().succeeded, + "one undo must restore the complete drag deletion"); + upper_rung = editor.findRung(logic_id, upper); + require( + upper_rung->cells[0].kind == LadderCellKind::Node + && upper_rung->cells[1].kind == LadderCellKind::Wire + && upper_rung->output.has_value() + && editor.findLogic(logic_id)->verticalConnections.size() == 1U, + "one undo must restore every object removed by the drag selection"); +} + +void testCursorAdvanceAndInlineCommandInput() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + const std::string logic_id = editor.ensureDefaultLogic().id; + LogicEditorWidget widget(editor); + widget.setLogicId(logic_id); + widget.resize(1320, 440); + widget.show(); + QCoreApplication::processEvents(QEventLoop::AllEvents); + + for (int column = 0; + column < ProjectLimits::kMaximumConditionColumns; + ++column) + { + require(widget.addHorizontalWire().succeeded, + "repeated toolbar wire input must advance through all ten cells"); + } + const ControlLogic *logic = editor.findLogic(logic_id); + require(logic != nullptr && logic->rungs.size() == 1U, + "the first wire on empty logic must atomically create one row"); + for (const LadderCell &cell : logic->rungs.front().cells) + { + require(cell.kind == LadderCellKind::Wire, + "ten repeated wire actions must fill ten distinct cells"); + } + + require(widget.setOutput( + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal}, + true).succeeded, + "the output action must succeed after the tenth cell"); + logic = editor.findLogic(logic_id); + require(logic->rungs.size() == 2U + && logic->rungs.front().output.has_value(), + "the output action must append the next empty row atomically"); + require(widget.addHorizontalWire().succeeded + && editor.findLogic(logic_id)->rungs[1].cells[0].kind + == LadderCellKind::Wire, + "the toolbar cursor must continue at the appended row first cell"); + + constexpr qreal left_bus = 60.0; + constexpr qreal cell_width = 96.0; + constexpr qreal output_width = 224.0; + constexpr qreal second_row_center = 197.0; + const auto double_click = [&widget](const QPointF &scene_point) + { + const QPoint point = widget.mapFromScene(scene_point); + QMouseEvent event( + QEvent::MouseButtonDblClick, + QPointF(point), + Qt::LeftButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &event); + QCoreApplication::processEvents(QEventLoop::AllEvents); + }; + const auto press_enter = [](QLineEdit *input) + { + QKeyEvent event( + QEvent::KeyPress, + Qt::Key_Return, + Qt::NoModifier); + QApplication::sendEvent(input, &event); + QCoreApplication::processEvents(QEventLoop::AllEvents); + }; + + double_click(QPointF( + left_bus + 1.5 * cell_width, + second_row_center)); + QLineEdit *input = widget.findChild( + QStringLiteral("logicCommandInput")); + require(input != nullptr && input->isVisible() + && input->completer() != nullptr, + "double-clicking a cell must open the inline command editor with completion"); + const int first_input_left = input->geometry().left(); + input->setText(QStringLiteral("LD M4")); + press_enter(input); + const LadderRung &second = editor.findLogic(logic_id)->rungs[1]; + require(second.cells[1].node.has_value() + && input->isVisible() + && input->geometry().left() > first_input_left, + "a committed inline condition must move the editor one cell right"); + + double_click(QPointF( + left_bus + ProjectLimits::kMaximumConditionColumns * cell_width + + output_width / 2.0, + second_row_center)); + input->setText(QStringLiteral("OUT M61")); + press_enter(input); + logic = editor.findLogic(logic_id); + require(logic->rungs.size() == 3U + && logic->rungs[1].output.has_value() + && input->isVisible(), + "an inline output must append a row and keep continuous input active"); +} + +void testSegmentLevelTraceProjection() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string upper = editor.addRung(logic_id).id; + const std::string lower = editor.addRung(logic_id).id; + const LogicEditorResult condition = editor.setConditionAtColumn( + logic_id, + upper, + 0, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 0}, + ContactMode::NormallyOpen}, + true); + const LogicEditorResult output = editor.setOutput( + logic_id, + upper, + CoilNodeConfig{ + RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, + true); + require(condition.succeeded && output.succeeded + && editor.setVerticalConnection( + logic_id, upper, lower, 0, true).succeeded, + "the segment trace fixture must create a contact, output and vertical edge"); + const ControlLogic *logic = editor.findLogic(logic_id); + const std::string cell_id = logic->rungs.front().cells.front().id; + const std::string vertical_id = logic->verticalConnections.front().id; + + LogicTraceSnapshot trace; + LogicTraceValues &values = trace.logicValues[logic_id]; + values.cellInputPowerValues[cell_id] = true; + values.cellPowerValues[cell_id] = false; + values.rungValues[upper] = false; + values.nodeValues[output.id] = false; + LogicEditorWidget widget(editor); + widget.setLogicId(logic_id); + widget.resize(1320, 360); + widget.show(); + widget.setRuntimeTrace(trace); + QCoreApplication::processEvents(QEventLoop::AllEvents); + + bool found_split_contact = false; + bool found_inactive_output = false; + bool found_inactive_vertical = false; + for (QGraphicsItem *item : widget.scene()->items()) + { + const QString type = item->data(0).toString(); + if (type == QStringLiteral("cell") + && item->data(1).toString().toStdString() == upper + && item->data(2).toInt() == 0) + { + found_split_contact = item->data(6).toBool() + && !item->data(7).toBool(); + } + else if (type == QStringLiteral("output") + && item->data(1).toString().toStdString() == upper) + { + found_inactive_output = !item->data(3).toBool() + && !item->data(4).toBool(); + } + else if (type == QStringLiteral("vertical") + && item->data(1).toString().toStdString() == vertical_id) + { + found_inactive_vertical = !item->data(5).toBool(); + } + } + require(found_split_contact && found_inactive_output + && found_inactive_vertical, + "trace projection must keep left/right contact power and inactive verticals separate"); + + values.cellPowerValues[cell_id] = true; + values.rungValues[upper] = true; + values.nodeValues[output.id] = true; + values.verticalConnectionValues[vertical_id] = true; + widget.setRuntimeTrace(trace); + bool found_active_output = false; + bool found_active_vertical = false; + for (QGraphicsItem *item : widget.scene()->items()) + { + const QString type = item->data(0).toString(); + if (type == QStringLiteral("output") + && item->data(1).toString().toStdString() == upper) + { + found_active_output = item->data(3).toBool() + && item->data(4).toBool(); + } + else if (type == QStringLiteral("vertical") + && item->data(1).toString().toStdString() == vertical_id) + { + found_active_vertical = item->data(5).toBool(); + } + } + require(found_active_output && found_active_vertical, + "explicitly energized output and vertical segments must project as active"); +} + +void testLogicClipboardUsesExplicitObjectAndRowSelection() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService editor(project_service); + const std::string logic_id = editor.ensureDefaultLogic().id; + const std::string source = editor.addRung(logic_id).id; + const std::string target = editor.addRung(logic_id).id; + require( + editor.setHorizontalWireRange( + logic_id, source, 0, 0, true).succeeded, + "clipboard fixture must create one source wire"); + editor.clearHistory(); + + LogicEditorWidget widget(editor); + widget.setLogicId(logic_id); + widget.resize(1320, 360); + widget.show(); + QCoreApplication::processEvents(QEventLoop::AllEvents); + const auto find_item = [&widget]( + const QString &type, + const std::string &rung_id, + int column) -> QGraphicsItem * + { + const QList items = widget.scene()->items(); + const auto found = std::find_if( + items.cbegin(), items.cend(), + [&type, &rung_id, column](QGraphicsItem *item) + { + return item->data(0).toString() == type + && item->data(1).toString().toStdString() == rung_id + && (column < 0 || item->data(2).toInt() == column); + }); + return found == items.cend() ? nullptr : *found; + }; + const auto click_item = [&widget](QGraphicsItem *item) + { + require(item != nullptr, "clipboard test target item must exist"); + const QPoint point = widget.mapFromScene( + item->sceneBoundingRect().center()); + QMouseEvent press( + QEvent::MouseButtonPress, + QPointF(point), + Qt::LeftButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &press); + QMouseEvent release( + QEvent::MouseButtonRelease, + QPointF(point), + Qt::LeftButton, + Qt::NoButton, + Qt::NoModifier); + QApplication::sendEvent(widget.viewport(), &release); + }; + + click_item(find_item(QStringLiteral("cell"), source, 0)); + const LogicClipboardCopyResult wire_copy = widget.copySelection(); + require( + widget.hasCopyableSelection() + && wire_copy.copy.succeeded + && wire_copy.fragment.mode == LogicClipboardMode::GridObjects + && wire_copy.fragment.cells.size() == 1U + && wire_copy.fragment.cells.front().kind == LadderCellKind::Wire + && wire_copy.fragment.rows.empty(), + "clicking one wire must copy one grid object instead of its whole row"); + + click_item(find_item(QStringLiteral("cell"), target, 0)); + const LogicClipboardPasteResult pasted = widget.pasteClipboard( + wire_copy.fragment); + require( + pasted.edit.succeeded + && editor.findLogic(logic_id)->rungs.size() == 2U + && editor.findCell(logic_id, target, 0)->kind + == LadderCellKind::Wire, + "widget paste must place one wire without creating or copying a row"); + const LogicClipboardCopyResult pasted_selection = widget.copySelection(); + require( + pasted_selection.copy.succeeded + && pasted_selection.fragment.cells.size() == 1U + && pasted_selection.fragment.cells.front().kind + == LadderCellKind::Wire, + "a successful paste must select the newly pasted wire"); + + click_item(find_item(QStringLiteral("rowHeader"), source, -1)); + const LogicClipboardCopyResult row_copy = widget.copySelection(); + require( + row_copy.copy.succeeded + && row_copy.fragment.mode == LogicClipboardMode::WholeRows + && row_copy.fragment.rows.size() == 1U, + "only clicking the left row header may create a whole-row clipboard"); +} + } // namespace int main(int argc, char *argv[]) @@ -151,6 +857,11 @@ int main(int argc, char *argv[]) try { testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); + testLogicEditorGridSelectionAndDeletion(); + testLadderLayoutAndDragDeletion(); + testCursorAdvanceAndInlineCommandInput(); + testSegmentLevelTraceProjection(); + testLogicClipboardUsesExplicitObjectAndRowSelection(); } catch (const std::exception &error) {