| @@ -229,7 +229,9 @@ bool validateUniqueExpressionIds( | |||
| void normalizeExpression(ConditionExpression *expression) | |||
| { | |||
| if (expression == nullptr || expression->kind == ConditionExpressionKind::Node) | |||
| if (expression == nullptr | |||
| || expression->kind == ConditionExpressionKind::Node | |||
| || expression->kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return; | |||
| } | |||
| @@ -479,6 +481,26 @@ ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | |||
| return expression; | |||
| } | |||
| bool WireSegment::validate(std::string *error) const | |||
| { | |||
| if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) | |||
| { | |||
| setError(error, "横线跨度必须在 1~256 列范围内"); | |||
| 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; | |||
| } | |||
| bool ConditionExpression::validate(std::string *error) const | |||
| { | |||
| if (id.empty()) | |||
| @@ -488,14 +510,30 @@ bool ConditionExpression::validate(std::string *error) const | |||
| } | |||
| if (kind == ConditionExpressionKind::Node) | |||
| { | |||
| if (!node.has_value() || !children.empty() || !node->isCondition()) | |||
| if (!node.has_value() || wire.has_value() | |||
| || !children.empty() || !node->isCondition()) | |||
| { | |||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | |||
| return false; | |||
| } | |||
| return node->validate(error); | |||
| } | |||
| if (node.has_value() || children.size() < 2U) | |||
| if (kind == ConditionExpressionKind::Wire) | |||
| { | |||
| if (node.has_value() || !wire.has_value() || !children.empty()) | |||
| { | |||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | |||
| return false; | |||
| } | |||
| return wire->validate(error); | |||
| } | |||
| if (kind != ConditionExpressionKind::Series | |||
| && kind != ConditionExpressionKind::Parallel) | |||
| { | |||
| setError(error, "条件表达式使用了不支持的类型"); | |||
| return false; | |||
| } | |||
| if (node.has_value() || wire.has_value() || children.size() < 2U) | |||
| { | |||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | |||
| return false; | |||
| @@ -530,6 +568,10 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| if (kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return true; | |||
| } | |||
| for (const ConditionExpression &child : children) | |||
| { | |||
| if (!child.validateForRunning(error)) | |||
| @@ -548,6 +590,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||
| } | |||
| normalizeExpression(&expression->value()); | |||
| if (expression->value().kind != ConditionExpressionKind::Node | |||
| && expression->value().kind != ConditionExpressionKind::Wire | |||
| && expression->value().children.empty()) | |||
| { | |||
| expression->reset(); | |||
| @@ -557,6 +600,10 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||
| const LogicNode *findConditionNode( | |||
| const ConditionExpression &expression, const std::string &node_id) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return nullptr; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| return expression.node->id == node_id ? &*expression.node : nullptr; | |||
| @@ -615,6 +662,10 @@ void collectConditionNodes( | |||
| nodes->push_back(&*expression.node); | |||
| return; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return; | |||
| } | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| collectConditionNodes(child, nodes); | |||
| @@ -204,19 +204,32 @@ struct LogicNode | |||
| enum class ConditionExpressionKind | |||
| { | |||
| Node, | |||
| Wire, | |||
| Series, | |||
| Parallel | |||
| }; | |||
| struct WireSegment | |||
| { | |||
| static constexpr int kMinimumColumnSpan = 1; | |||
| static constexpr int kMaximumColumnSpan = 256; | |||
| int columnSpan = 1; | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | |||
| struct ConditionExpression | |||
| { | |||
| std::string id; | |||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; | |||
| std::optional<LogicNode> node; | |||
| std::optional<WireSegment> wire; | |||
| std::vector<ConditionExpression> children; | |||
| static ConditionExpression fromNode(LogicNode node); | |||
| static ConditionExpression fromWire(std::string id, int column_span = 1); | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -1468,6 +1468,8 @@ QString expressionKindText(ConditionExpressionKind kind) | |||
| { | |||
| case ConditionExpressionKind::Node: | |||
| return QStringLiteral("node"); | |||
| case ConditionExpressionKind::Wire: | |||
| return QStringLiteral("wire"); | |||
| case ConditionExpressionKind::Series: | |||
| return QStringLiteral("series"); | |||
| case ConditionExpressionKind::Parallel: | |||
| @@ -1485,6 +1487,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression) | |||
| { | |||
| object.insert(QStringLiteral("node"), serializeLogicNode(*expression.node)); | |||
| } | |||
| else if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | |||
| } | |||
| else | |||
| { | |||
| QJsonArray children; | |||
| @@ -1525,11 +1531,29 @@ bool parseConditionExpression( | |||
| 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 != "series" && kind != "parallel") | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".kind 必须是 node、series 或 parallel"); | |||
| context + ".kind 必须是 node、wire、series 或 parallel"); | |||
| } | |||
| QJsonArray children; | |||
| if (!readArray(object, "children", context, &children, state)) | |||
| @@ -75,18 +75,43 @@ ConditionExpression *findParentExpression( | |||
| using NodeIdSet = std::unordered_set<std::string>; | |||
| NodeIdSet conditionNodeIds(const ConditionExpression &expression) | |||
| NodeIdSet conditionLeafIds(const ConditionExpression &expression) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| collectConditionNodes(expression, &nodes); | |||
| NodeIdSet ids; | |||
| for (const LogicNode *node : nodes) | |||
| if (expression.kind == ConditionExpressionKind::Node | |||
| || expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| ids.insert(node->id); | |||
| 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; | |||
| } | |||
| int expressionColumns(const ConditionExpression &expression) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| return 1; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return expression.wire->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; | |||
| } | |||
| bool isSubset(const NodeIdSet &subset, const NodeIdSet &values) | |||
| { | |||
| return std::all_of( | |||
| @@ -124,7 +149,7 @@ bool addParallelForSelection( | |||
| const std::string ¶llel_id, | |||
| const std::string &series_id) | |||
| { | |||
| const NodeIdSet expression_ids = conditionNodeIds(*expression); | |||
| const NodeIdSet expression_ids = conditionLeafIds(*expression); | |||
| if (sameValues(expression_ids, selected_ids)) | |||
| { | |||
| addParallelSibling(expression, std::move(*branch), parallel_id); | |||
| @@ -136,7 +161,7 @@ bool addParallelForSelection( | |||
| std::vector<std::size_t> matching_children; | |||
| for (std::size_t index = 0; index < expression->children.size(); ++index) | |||
| { | |||
| child_ids.push_back(conditionNodeIds(expression->children[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) | |||
| @@ -212,7 +237,9 @@ bool addParallelForSelection( | |||
| bool removeExpressionRecursive( | |||
| ConditionExpression *expression, const std::string &expression_id) | |||
| { | |||
| if (expression == nullptr || expression->kind == ConditionExpressionKind::Node) | |||
| if (expression == nullptr | |||
| || expression->kind == ConditionExpressionKind::Node | |||
| || expression->kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -238,6 +265,65 @@ bool removeExpressionRecursive( | |||
| return false; | |||
| } | |||
| std::optional<int> selectedExpressionColumns( | |||
| const ConditionExpression &expression, | |||
| const NodeIdSet &selected_ids) | |||
| { | |||
| const NodeIdSet expression_ids = conditionLeafIds(expression); | |||
| if (sameValues(expression_ids, selected_ids)) | |||
| { | |||
| return expressionColumns(expression); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node | |||
| || expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| std::vector<NodeIdSet> child_ids; | |||
| std::vector<std::size_t> 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) | |||
| { | |||
| return selected_ids.count(id) != 0U; | |||
| }); | |||
| if (intersects) | |||
| { | |||
| matching_children.push_back(index); | |||
| } | |||
| } | |||
| 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<int>{columns} : std::nullopt; | |||
| } | |||
| LadderRung *findEditableRung( | |||
| Project &project, | |||
| const std::string &logic_id, | |||
| @@ -329,6 +415,7 @@ bool LogicEditorService::expressionsEqual( | |||
| { | |||
| 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.children.size() != right.children.size()) | |||
| { | |||
| return false; | |||
| @@ -337,6 +424,11 @@ bool LogicEditorService::expressionsEqual( | |||
| { | |||
| return false; | |||
| } | |||
| if (left.wire.has_value() | |||
| && left.wire->columnSpan != right.wire->columnSpan) | |||
| { | |||
| return false; | |||
| } | |||
| for (std::size_t index = 0; index < left.children.size(); ++index) | |||
| { | |||
| if (!expressionsEqual(left.children[index], right.children[index])) | |||
| @@ -809,6 +901,50 @@ LogicEditorResult LogicEditorService::appendCondition( | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::appendWire( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column_span) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) | |||
| { | |||
| return failure( | |||
| logic == nullptr ? LogicEditorError::LogicNotFound | |||
| : LogicEditorError::RungNotFound, | |||
| logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络"); | |||
| } | |||
| WireSegment wire{column_span}; | |||
| std::string error; | |||
| if (!wire.validate(&error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, error); | |||
| } | |||
| const std::string wire_id = makeUniqueWireId(*logic); | |||
| ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); | |||
| HistoryState before = captureState(); | |||
| LadderRung *rung = findEditableRung( | |||
| project_service_.editProject(), logic_id, rung_id); | |||
| 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(*logic), | |||
| ConditionExpressionKind::Series, | |||
| std::move(*rung->condition), | |||
| std::move(leaf)); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::insertConditionAfter( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -858,6 +994,91 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::insertWireAfter( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_expression_id, | |||
| int column_span) | |||
| { | |||
| 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) | |||
| { | |||
| return failure(LogicEditorError::ExpressionNotFound, "未找到横线插入位置"); | |||
| } | |||
| if ((target->kind != ConditionExpressionKind::Node | |||
| && target->kind != ConditionExpressionKind::Wire) | |||
| || !wire.validate(&error)) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| error.empty() ? "只能在触点或横线后插入横线" : error); | |||
| } | |||
| const std::string wire_id = makeUniqueWireId(*logic); | |||
| ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); | |||
| HistoryState before = captureState(); | |||
| LadderRung *rung = findEditableRung( | |||
| project_service_.editProject(), 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) | |||
| { | |||
| return child.id == target_expression_id; | |||
| }); | |||
| parent->children.insert(target_iterator + 1, std::move(leaf)); | |||
| } | |||
| else | |||
| { | |||
| ConditionExpression original = std::move(*editable_target); | |||
| *editable_target = makeContainer( | |||
| makeUniqueExpressionId(*logic), | |||
| ConditionExpressionKind::Series, | |||
| std::move(original), | |||
| std::move(leaf)); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::replaceWireWithCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| const LogicNodeConfig &config) | |||
| { | |||
| 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)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点"); | |||
| } | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| HistoryState before = captureState(); | |||
| 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)); | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addParallelBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -885,7 +1106,7 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||
| LogicEditorError::InvalidOperation, | |||
| "并联选择中存在重复节点"); | |||
| } | |||
| const NodeIdSet available_ids = conditionNodeIds(*existing_rung->condition); | |||
| const NodeIdSet available_ids = conditionLeafIds(*existing_rung->condition); | |||
| if (!isSubset(selected_ids, available_ids)) | |||
| { | |||
| return failure( | |||
| @@ -918,6 +1139,72 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addParallelWireBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &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<int> 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(); | |||
| LadderRung *rung = findEditableRung( | |||
| project_service_.editProject(), logic_id, rung_id); | |||
| if (!addParallelForSelection( | |||
| &*rung->condition, | |||
| selected_ids, | |||
| &wire, | |||
| parallel_id, | |||
| series_id)) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "竖线连接只能围绕同一支路中的连续逻辑范围"); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::setOutput( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -1100,6 +1387,58 @@ LogicEditorResult LogicEditorService::removeExpression( | |||
| return {true, LogicEditorError::None, {}, expression_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::removeExpressions( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &expression_ids) | |||
| { | |||
| const LadderRung *existing_rung = findRung(logic_id, rung_id); | |||
| if (existing_rung == nullptr || !existing_rung->condition.has_value()) | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络"); | |||
| } | |||
| if (expression_ids.empty()) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "请先选择要删除的条件或横线"); | |||
| } | |||
| NodeIdSet unique_ids; | |||
| for (const std::string &expression_id : expression_ids) | |||
| { | |||
| const ConditionExpression *expression = findExpression( | |||
| logic_id, rung_id, expression_id); | |||
| if (!unique_ids.insert(expression_id).second) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复对象"); | |||
| } | |||
| if (expression == nullptr) | |||
| { | |||
| return failure( | |||
| LogicEditorError::ExpressionNotFound, | |||
| "删除列表中包含未知条件表达式"); | |||
| } | |||
| } | |||
| HistoryState before = captureState(); | |||
| LadderRung *rung = findEditableRung( | |||
| project_service_.editProject(), logic_id, rung_id); | |||
| for (const std::string &expression_id : expression_ids) | |||
| { | |||
| if (!rung->condition.has_value()) | |||
| { | |||
| break; | |||
| } | |||
| if (rung->condition->id == expression_id) | |||
| { | |||
| rung->condition.reset(); | |||
| break; | |||
| } | |||
| removeExpressionRecursive(&*rung->condition, expression_id); | |||
| } | |||
| normalizeConditionExpression(&rung->condition); | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, expression_ids.front()}; | |||
| } | |||
| bool LogicEditorService::canUndo() const | |||
| { | |||
| return history_.canUndo(); | |||
| @@ -1225,6 +1564,25 @@ std::string LogicEditorService::makeUniqueNodeId( | |||
| } | |||
| } | |||
| 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 rung.condition.has_value() | |||
| && findConditionExpression(*rung.condition, candidate) != nullptr; | |||
| }); | |||
| if (!found) | |||
| { | |||
| return candidate; | |||
| } | |||
| } | |||
| } | |||
| std::string LogicEditorService::makeUniqueExpressionId(const ControlLogic &logic) | |||
| { | |||
| for (std::size_t index = 1;; ++index) | |||
| @@ -68,16 +68,34 @@ public: | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult appendWire( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column_span = 1); | |||
| LogicEditorResult insertConditionAfter( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_node_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult insertWireAfter( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_expression_id, | |||
| int column_span = 1); | |||
| LogicEditorResult replaceWireWithCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult addParallelBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_node_ids, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult addParallelWireBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_expression_ids); | |||
| LogicEditorResult setOutput( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -96,6 +114,10 @@ public: | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &expression_id); | |||
| LogicEditorResult removeExpressions( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &expression_ids); | |||
| bool canUndo() const; | |||
| bool canRedo() const; | |||
| @@ -129,6 +151,7 @@ private: | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| static std::string makeUniqueNodeId( | |||
| const ControlLogic &logic, const std::string &prefix); | |||
| static std::string makeUniqueWireId(const ControlLogic &logic); | |||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | |||
| static std::string makeUniqueRungId(const ControlLogic &logic); | |||
| static LogicEditorResult failure( | |||
| @@ -74,7 +74,10 @@ bool coilModesAreCompatible(CoilMode existing, CoilMode current) | |||
| void LogicTraceValues::clear() | |||
| { | |||
| nodeValues.clear(); | |||
| nodePowerValues.clear(); | |||
| expressionValues.clear(); | |||
| expressionInputValues.clear(); | |||
| expressionPowerValues.clear(); | |||
| rungValues.clear(); | |||
| tonValues.clear(); | |||
| counterValues.clear(); | |||
| @@ -95,7 +98,10 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| if (values != logicValues.cend()) | |||
| { | |||
| 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.rungValues = values->second.rungValues; | |||
| projection.tonValues = values->second.tonValues; | |||
| projection.counterValues = values->second.counterValues; | |||
| @@ -224,7 +230,12 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| bool rung_value = false; | |||
| LogicScanResult result = evaluateExpression( | |||
| logic.id, *rung.condition, repository, logic_trace, &rung_value); | |||
| logic.id, | |||
| *rung.condition, | |||
| repository, | |||
| logic_trace, | |||
| true, | |||
| &rung_value); | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| @@ -235,6 +246,7 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| { | |||
| 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( | |||
| @@ -254,6 +266,7 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| if (logic_trace != nullptr) | |||
| { | |||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||
| logic_trace->nodePowerValues[rung.output->id] = output_value; | |||
| } | |||
| } | |||
| } | |||
| @@ -268,7 +281,10 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| if (values != trace->logicValues.cend()) | |||
| { | |||
| 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->rungValues = values->second.rungValues; | |||
| trace->tonValues = values->second.tonValues; | |||
| trace->counterValues = values->second.counterValues; | |||
| @@ -284,12 +300,27 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| 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::Node) | |||
| { | |||
| LogicScanResult result = evaluateCondition( | |||
| @@ -297,28 +328,43 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| 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_value); | |||
| 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(); | |||
| } | |||
| @@ -56,7 +56,10 @@ struct WordTraceValue | |||
| struct LogicTraceValues | |||
| { | |||
| std::unordered_map<std::string, bool> nodeValues; | |||
| std::unordered_map<std::string, bool> nodePowerValues; | |||
| std::unordered_map<std::string, bool> expressionValues; | |||
| std::unordered_map<std::string, bool> expressionInputValues; | |||
| std::unordered_map<std::string, bool> expressionPowerValues; | |||
| std::unordered_map<std::string, bool> rungValues; | |||
| std::unordered_map<std::string, TonTraceValue> tonValues; | |||
| std::unordered_map<std::string, CounterTraceValue> counterValues; | |||
| @@ -111,6 +114,7 @@ private: | |||
| const ConditionExpression &expression, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool input_power, | |||
| bool *value); | |||
| LogicScanResult executeOutput( | |||
| const std::string &logic_id, | |||
| @@ -182,6 +182,10 @@ ExpressionMetrics measureExpression(const ConditionExpression &expression) | |||
| { | |||
| return {}; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return {expression.wire->columnSpan, 1}; | |||
| } | |||
| ExpressionMetrics metrics{0, 0}; | |||
| if (expression.kind == ConditionExpressionKind::Series) | |||
| @@ -273,14 +277,16 @@ public: | |||
| const QPointF ¢er, | |||
| const QString ®ister_comment, | |||
| bool active, | |||
| bool faulted) | |||
| 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) | |||
| faulted_(faulted), | |||
| condition_(condition) | |||
| { | |||
| setPos(center); | |||
| setFlag(ItemIsSelectable, true); | |||
| @@ -518,6 +524,7 @@ public: | |||
| 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_; | |||
| @@ -527,6 +534,110 @@ private: | |||
| 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<qreal>(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; | |||
| }; | |||
| class LogicEditorWidget::VerticalConnectorItem final : public QGraphicsItem | |||
| { | |||
| public: | |||
| VerticalConnectorItem( | |||
| const std::string &branch_expression_id, | |||
| const std::string &rung_id, | |||
| qreal x, | |||
| qreal top, | |||
| qreal bottom, | |||
| bool active) | |||
| : branch_expression_id_(branch_expression_id), | |||
| rung_id_(rung_id), | |||
| height_(bottom - top), | |||
| active_(active) | |||
| { | |||
| setPos(x, top); | |||
| setFlag(ItemIsSelectable, true); | |||
| setZValue(2.5); | |||
| setToolTip(LogicEditorWidget::tr("竖线连接:删除将移除对应并联支路")); | |||
| } | |||
| QRectF boundingRect() const override | |||
| { | |||
| return {-8.0, 0.0, 16.0, height_}; | |||
| } | |||
| void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) 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)); | |||
| } | |||
| } | |||
| const std::string &branchExpressionId() const | |||
| { | |||
| return branch_expression_id_; | |||
| } | |||
| 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 | |||
| @@ -627,15 +738,31 @@ RenderResult renderExpression( | |||
| bool trace_enabled, | |||
| const std::string &fault_node_id) | |||
| { | |||
| const bool active = trace_enabled && traceValue( | |||
| trace, &LogicTraceSnapshot::expressionValues, expression.id); | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| const bool active = trace_enabled && traceValue( | |||
| trace, &LogicTraceSnapshot::expressionPowerValues, expression.id); | |||
| const qreal width = static_cast<qreal>(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)); | |||
| 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::nodeValues, expression.node->id); | |||
| trace, &LogicTraceSnapshot::nodePowerValues, expression.node->id); | |||
| scene.addLine( | |||
| QLineF( | |||
| QPointF(top_left.x(), center.y()), | |||
| @@ -655,7 +782,8 @@ RenderResult renderExpression( | |||
| : QString{}; | |||
| }(), | |||
| node_active, | |||
| expression.node->id == fault_node_id)); | |||
| expression.node->id == fault_node_id, | |||
| true)); | |||
| return { | |||
| QPointF(top_left.x(), center.y()), | |||
| QPointF(top_left.x() + kCellWidth, center.y())}; | |||
| @@ -685,7 +813,13 @@ RenderResult renderExpression( | |||
| } | |||
| else | |||
| { | |||
| scene.addLine(QLineF(previous.output, current.input), ladderPen(active)); | |||
| const bool previous_active = trace_enabled && traceValue( | |||
| trace, | |||
| &LogicTraceSnapshot::expressionPowerValues, | |||
| expression.children[index - 1U].id); | |||
| scene.addLine( | |||
| QLineF(previous.output, current.input), | |||
| ladderPen(previous_active)); | |||
| } | |||
| previous = current; | |||
| x += static_cast<qreal>(child_metrics.columns) * kCellWidth; | |||
| @@ -715,14 +849,36 @@ RenderResult renderExpression( | |||
| const qreal left_join = top_left.x(); | |||
| const qreal right_join = top_left.x() + static_cast<qreal>(metrics.columns) * kCellWidth; | |||
| const qreal top_y = branches.front().input.y(); | |||
| const qreal bottom_y = branches.back().input.y(); | |||
| scene.addLine(QLineF(left_join, top_y, left_join, bottom_y), ladderPen(active)); | |||
| scene.addLine(QLineF(right_join, top_y, right_join, bottom_y), ladderPen(active)); | |||
| 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::expressionValues, | |||
| &LogicTraceSnapshot::expressionPowerValues, | |||
| expression.children[index].id); | |||
| scene.addLine( | |||
| QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), | |||
| @@ -898,7 +1054,8 @@ void LogicEditorWidget::reloadLogic() | |||
| rung.output->id) | |||
| : rung_active; | |||
| }(), | |||
| rung.output->id == fault_node_id_)); | |||
| rung.output->id == fault_node_id_, | |||
| false)); | |||
| } | |||
| else | |||
| { | |||
| @@ -938,6 +1095,38 @@ void LogicEditorWidget::selectNode(const std::string &node_id) | |||
| } | |||
| } | |||
| void LogicEditorWidget::selectExpression(const std::string &expression_id) | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| bool selected = false; | |||
| if (NodeItem *node = dynamic_cast<NodeItem *>(item)) | |||
| { | |||
| selected = node->isConditionNode() && node->nodeId() == expression_id; | |||
| if (selected) | |||
| { | |||
| current_rung_id_ = node->rungId(); | |||
| ensureVisible(node); | |||
| } | |||
| } | |||
| else if (WireItem *wire = dynamic_cast<WireItem *>(item)) | |||
| { | |||
| selected = wire->expressionId() == expression_id; | |||
| if (selected) | |||
| { | |||
| current_rung_id_ = wire->rungId(); | |||
| ensureVisible(wire); | |||
| } | |||
| } | |||
| item->setSelected(selected); | |||
| } | |||
| } | |||
| void LogicEditorWidget::selectWire(const std::string &wire_id) | |||
| { | |||
| selectExpression(wire_id); | |||
| } | |||
| std::string LogicEditorWidget::selectedNodeId() const | |||
| { | |||
| const std::vector<std::string> ids = selectedNodeIds(); | |||
| @@ -973,6 +1162,73 @@ std::vector<std::string> LogicEditorWidget::selectedNodeIds() const | |||
| return ids; | |||
| } | |||
| std::vector<std::string> LogicEditorWidget::selectedExpressionIds() const | |||
| { | |||
| std::vector<std::pair<QPointF, std::string>> positioned_ids; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| if (const NodeItem *node = dynamic_cast<const NodeItem *>(item)) | |||
| { | |||
| if (node->isConditionNode()) | |||
| { | |||
| positioned_ids.emplace_back(node->scenePos(), node->nodeId()); | |||
| } | |||
| } | |||
| else if (const WireItem *wire = dynamic_cast<const WireItem *>(item)) | |||
| { | |||
| positioned_ids.emplace_back(wire->scenePos(), wire->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<std::string> ids; | |||
| ids.reserve(positioned_ids.size()); | |||
| for (const auto &positioned_id : positioned_ids) | |||
| { | |||
| ids.push_back(positioned_id.second); | |||
| } | |||
| return ids; | |||
| } | |||
| std::vector<std::string> LogicEditorWidget::selectedWireIds() const | |||
| { | |||
| std::vector<std::string> ids; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| if (const WireItem *wire = dynamic_cast<const WireItem *>(item)) | |||
| { | |||
| ids.push_back(wire->expressionId()); | |||
| } | |||
| } | |||
| return ids; | |||
| } | |||
| std::vector<std::string> LogicEditorWidget::selectedBranchIds() const | |||
| { | |||
| std::vector<std::string> ids; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| if (const VerticalConnectorItem *connector = | |||
| dynamic_cast<const VerticalConnectorItem *>(item)) | |||
| { | |||
| if (std::find(ids.cbegin(), ids.cend(), connector->branchExpressionId()) | |||
| == ids.cend()) | |||
| { | |||
| ids.push_back(connector->branchExpressionId()); | |||
| } | |||
| } | |||
| } | |||
| return ids; | |||
| } | |||
| std::string LogicEditorWidget::selectedRungId() const | |||
| { | |||
| std::string rung_id; | |||
| @@ -986,6 +1242,23 @@ std::string LogicEditorWidget::selectedRungId() const | |||
| } | |||
| rung_id = node->rungId(); | |||
| } | |||
| else if (const WireItem *wire = dynamic_cast<const WireItem *>(item)) | |||
| { | |||
| if (!rung_id.empty() && rung_id != wire->rungId()) | |||
| { | |||
| return {}; | |||
| } | |||
| rung_id = wire->rungId(); | |||
| } | |||
| else if (const VerticalConnectorItem *connector = | |||
| dynamic_cast<const VerticalConnectorItem *>(item)) | |||
| { | |||
| if (!rung_id.empty() && rung_id != connector->rungId()) | |||
| { | |||
| return {}; | |||
| } | |||
| rung_id = connector->rungId(); | |||
| } | |||
| else if (const RungItem *rung = dynamic_cast<const RungItem *>(item)) | |||
| { | |||
| if (rung_id.empty()) | |||
| @@ -1015,12 +1288,19 @@ LogicEditorResult LogicEditorWidget::addRung() | |||
| LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) | |||
| { | |||
| const std::vector<std::string> selected_expressions = selectedExpressionIds(); | |||
| const std::vector<std::string> selected_wires = selectedWireIds(); | |||
| const std::vector<std::string> selected_ids = selectedNodeIds(); | |||
| LogicEditorResult result; | |||
| if (selected_ids.size() > 1U) | |||
| if (selected_expressions.size() > 1U || selected_wires.size() > 1U) | |||
| { | |||
| result = {false, LogicEditorError::InvalidOperation, | |||
| "串联插入时只能选择一个节点", {}}; | |||
| "串联插入或替换横线时只能选择一个条件对象", {}}; | |||
| } | |||
| else if (selected_wires.size() == 1U) | |||
| { | |||
| result = editor_service_.replaceWireWithCondition( | |||
| logic_id_, currentRungId(), selected_wires.front(), config); | |||
| } | |||
| else if (selected_ids.size() == 1U) | |||
| { | |||
| @@ -1038,7 +1318,7 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| selectExpression(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| @@ -1075,7 +1355,133 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| selectExpression(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::addHorizontalWire() | |||
| { | |||
| const std::vector<std::string> selected_ids = selectedExpressionIds(); | |||
| LogicEditorResult result; | |||
| if (selected_ids.size() > 1U) | |||
| { | |||
| result = {false, LogicEditorError::InvalidOperation, | |||
| "插入横线时只能选择一个条件对象", {}}; | |||
| } | |||
| else if (selected_ids.size() == 1U) | |||
| { | |||
| result = editor_service_.insertWireAfter( | |||
| logic_id_, currentRungId(), selected_ids.front()); | |||
| } | |||
| else | |||
| { | |||
| result = editor_service_.appendWire(logic_id_, currentRungId()); | |||
| } | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| selectWire(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::addVerticalWire() | |||
| { | |||
| const std::string rung_id = selectedRungId(); | |||
| const std::vector<std::string> selected_ids = selectedExpressionIds(); | |||
| LogicEditorResult result; | |||
| if (rung_id.empty() || selected_ids.empty()) | |||
| { | |||
| result = {false, LogicEditorError::InvalidOperation, | |||
| "请在同一网络中选择要连接的连续条件或横线", {}}; | |||
| } | |||
| else | |||
| { | |||
| result = editor_service_.addParallelWireBranch( | |||
| logic_id_, rung_id, selected_ids); | |||
| } | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| selectWire(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | |||
| { | |||
| const std::vector<std::string> wire_ids = selectedWireIds(); | |||
| const std::string rung_id = selectedRungId(); | |||
| if (wire_ids.empty() || rung_id.empty()) | |||
| { | |||
| const LogicEditorResult result = { | |||
| false, | |||
| LogicEditorError::InvalidOperation, | |||
| "请先选择要删除的横线", | |||
| {}}; | |||
| reportFailure(result); | |||
| return result; | |||
| } | |||
| const LogicEditorResult result = editor_service_.removeExpressions( | |||
| logic_id_, rung_id, wire_ids); | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| emit nodeSelected({}); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::deleteVerticalWire() | |||
| { | |||
| const std::vector<std::string> branch_ids = selectedBranchIds(); | |||
| if (branch_ids.empty()) | |||
| { | |||
| const LogicEditorResult result = { | |||
| false, | |||
| LogicEditorError::InvalidOperation, | |||
| "请先选择要删除的竖线连接", | |||
| {}}; | |||
| reportFailure(result); | |||
| return result; | |||
| } | |||
| const std::string rung_id = selectedRungId(); | |||
| LogicEditorResult result; | |||
| if (rung_id.empty()) | |||
| { | |||
| result = {false, LogicEditorError::InvalidOperation, | |||
| "竖线连接必须位于同一网络", {}}; | |||
| } | |||
| else | |||
| { | |||
| result = editor_service_.removeExpressions( | |||
| logic_id_, rung_id, branch_ids); | |||
| } | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| emit nodeSelected({}); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| @@ -1105,9 +1511,38 @@ LogicEditorResult LogicEditorWidget::setOutput( | |||
| LogicEditorResult LogicEditorWidget::deleteSelected() | |||
| { | |||
| const std::vector<std::string> expression_ids = selectedExpressionIds(); | |||
| const std::vector<std::string> wire_ids = selectedWireIds(); | |||
| const std::vector<std::string> branch_ids = selectedBranchIds(); | |||
| const std::vector<std::string> node_ids = selectedNodeIds(); | |||
| LogicEditorResult result; | |||
| if (!node_ids.empty()) | |||
| if (!branch_ids.empty()) | |||
| { | |||
| result = editor_service_.removeExpressions( | |||
| logic_id_, selectedRungId(), branch_ids); | |||
| } | |||
| else if (!wire_ids.empty()) | |||
| { | |||
| bool output_selected = false; | |||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||
| { | |||
| if (const NodeItem *node = dynamic_cast<const NodeItem *>(item)) | |||
| { | |||
| output_selected = output_selected || !node->isConditionNode(); | |||
| } | |||
| } | |||
| if (output_selected) | |||
| { | |||
| result = {false, LogicEditorError::InvalidOperation, | |||
| "不能同时删除横线和输出节点", {}}; | |||
| } | |||
| else | |||
| { | |||
| result = editor_service_.removeExpressions( | |||
| logic_id_, selectedRungId(), expression_ids); | |||
| } | |||
| } | |||
| else if (!node_ids.empty()) | |||
| { | |||
| result = editor_service_.removeNodes(logic_id_, node_ids); | |||
| } | |||
| @@ -18,6 +18,8 @@ class LogicEditorWidget final : public QGraphicsView | |||
| public: | |||
| class NodeItem; | |||
| class WireItem; | |||
| class VerticalConnectorItem; | |||
| class RungItem; | |||
| explicit LogicEditorWidget( | |||
| @@ -39,6 +41,10 @@ public: | |||
| LogicEditorResult addRung(); | |||
| 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 deleteSelected(); | |||
| @@ -55,6 +61,11 @@ private: | |||
| void handleSelectionChanged(); | |||
| 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<std::string> selectedExpressionIds() const; | |||
| std::vector<std::string> selectedWireIds() const; | |||
| std::vector<std::string> selectedBranchIds() const; | |||
| LogicEditorService &editor_service_; | |||
| QGraphicsScene *scene_ = nullptr; | |||
| @@ -395,6 +395,14 @@ void MainWindow::configureActions() | |||
| connect(ui_->addRungAction, &QAction::triggered, | |||
| this, &MainWindow::addLogicRung); | |||
| connect(ui_->insertHorizontalWireAction, &QAction::triggered, | |||
| this, &MainWindow::addLogicHorizontalWire); | |||
| connect(ui_->insertVerticalWireAction, &QAction::triggered, | |||
| this, &MainWindow::addLogicVerticalWire); | |||
| connect(ui_->deleteHorizontalWireAction, &QAction::triggered, | |||
| this, &MainWindow::deleteLogicHorizontalWire); | |||
| connect(ui_->deleteVerticalWireAction, &QAction::triggered, | |||
| this, &MainWindow::deleteLogicVerticalWire); | |||
| connect(ui_->parallelInsertAction, &QAction::triggered, | |||
| this, | |||
| [this] | |||
| @@ -727,6 +735,10 @@ void MainWindow::configureAppearance() | |||
| ui_->deleteControlAction->setIcon(makeUiIcon(UiIcon::Delete)); | |||
| ui_->addRungAction->setIcon(makeUiIcon(UiIcon::AddRung)); | |||
| ui_->parallelInsertAction->setIcon(makeUiIcon(UiIcon::ParallelBranch)); | |||
| ui_->insertHorizontalWireAction->setIcon(makeUiIcon(UiIcon::HorizontalWire)); | |||
| ui_->insertVerticalWireAction->setIcon(makeUiIcon(UiIcon::VerticalWire)); | |||
| ui_->deleteHorizontalWireAction->setIcon(makeUiIcon(UiIcon::Delete)); | |||
| ui_->deleteVerticalWireAction->setIcon(makeUiIcon(UiIcon::Delete)); | |||
| ui_->addNormallyOpenAction->setIcon(makeUiIcon(UiIcon::NormallyOpenContact)); | |||
| ui_->addNormallyClosedAction->setIcon(makeUiIcon(UiIcon::NormallyClosedContact)); | |||
| ui_->addRisingEdgeAction->setIcon(makeUiIcon(UiIcon::RisingEdgeContact)); | |||
| @@ -1030,6 +1042,10 @@ void MainWindow::updateEditActions() | |||
| || (logic_active && logic_editor_service_.canRedo()))); | |||
| 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_->deleteHorizontalWireAction->setEnabled(editable && logic_active); | |||
| ui_->deleteVerticalWireAction->setEnabled(editable && logic_active); | |||
| } | |||
| void MainWindow::clearEditorHistories() | |||
| @@ -1194,6 +1210,62 @@ void MainWindow::addLogicParallelBranch(const LogicNodeConfig &config) | |||
| statusBar()->showMessage(tr("已建立并联支路,请配置新触点"), 3000); | |||
| } | |||
| void MainWindow::addLogicHorizontalWire() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->addHorizontalWire(); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("插入横线"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("横线已插入,可直接用触点替换"), 3000); | |||
| } | |||
| void MainWindow::addLogicVerticalWire() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->addVerticalWire(); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("插入竖线"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已建立横线旁路和竖线连接"), 3000); | |||
| } | |||
| void MainWindow::deleteLogicHorizontalWire() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->deleteHorizontalWire(); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("删除横线"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("横线已删除"), 3000); | |||
| } | |||
| void MainWindow::deleteLogicVerticalWire() | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->deleteVerticalWire(); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("删除竖线"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("竖线及对应并联支路已删除"), 3000); | |||
| } | |||
| void MainWindow::setLogicOutput(const LogicNodeConfig &config) | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->setOutput(config); | |||
| @@ -136,6 +136,10 @@ private: | |||
| void addLogicCondition(const LogicNodeConfig &config); | |||
| // 为当前选中的连续逻辑范围建立并联支路 | |||
| void addLogicParallelBranch(const LogicNodeConfig &config); | |||
| void addLogicHorizontalWire(); | |||
| void addLogicVerticalWire(); | |||
| void deleteLogicHorizontalWire(); | |||
| void deleteLogicVerticalWire(); | |||
| // 设置当前网络右侧的输出线圈 | |||
| void setLogicOutput(const LogicNodeConfig &config); | |||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | |||
| @@ -250,6 +250,8 @@ | |||
| <addaction name="redoAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="deleteSelectionAction"/> | |||
| <addaction name="deleteHorizontalWireAction"/> | |||
| <addaction name="deleteVerticalWireAction"/> | |||
| <addaction name="clearSelectionAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="runMenu"> | |||
| @@ -331,6 +333,8 @@ | |||
| <bool>false</bool> | |||
| </attribute> | |||
| <addaction name="addRungAction"/> | |||
| <addaction name="insertHorizontalWireAction"/> | |||
| <addaction name="insertVerticalWireAction"/> | |||
| <addaction name="parallelInsertAction"/> | |||
| <addaction name="deleteLogicAction"/> | |||
| <addaction name="separator"/> | |||
| @@ -1059,6 +1063,50 @@ | |||
| <string>在梯形图末尾新增网络</string> | |||
| </property> | |||
| </action> | |||
| <action name="insertHorizontalWireAction"> | |||
| <property name="text"> | |||
| <string>横线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F11</string> | |||
| </property> | |||
| </action> | |||
| <action name="insertVerticalWireAction"> | |||
| <property name="text"> | |||
| <string>竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>为所选连续逻辑范围建立横线旁路和竖线连接</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F12</string> | |||
| </property> | |||
| </action> | |||
| <action name="deleteHorizontalWireAction"> | |||
| <property name="text"> | |||
| <string>删除横线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>删除画布中选中的横线</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Shift+F11</string> | |||
| </property> | |||
| </action> | |||
| <action name="deleteVerticalWireAction"> | |||
| <property name="text"> | |||
| <string>删除竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>删除画布中选中的竖线及其对应并联支路</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Shift+F12</string> | |||
| </property> | |||
| </action> | |||
| <action name="parallelInsertAction"> | |||
| <property name="text"> | |||
| <string>并联支路</string> | |||
| @@ -359,6 +359,24 @@ QPixmap renderIcon(UiIcon icon, int size) | |||
| painter.drawLine(QPointF(7, 12), QPointF(17, 12)); | |||
| break; | |||
| } | |||
| case UiIcon::HorizontalWire: | |||
| { | |||
| painter.drawLine(QPointF(3, 12), QPointF(21, 12)); | |||
| painter.setPen(iconPen(kAccent, 2.0)); | |||
| painter.drawLine(QPointF(9, 7), QPointF(15, 7)); | |||
| painter.drawLine(QPointF(12, 4), QPointF(12, 10)); | |||
| break; | |||
| } | |||
| case UiIcon::VerticalWire: | |||
| { | |||
| painter.drawLine(QPointF(5, 7), QPointF(19, 7)); | |||
| painter.drawLine(QPointF(5, 17), QPointF(19, 17)); | |||
| painter.drawLine(QPointF(12, 7), QPointF(12, 17)); | |||
| painter.setPen(iconPen(kAccent, 2.0)); | |||
| painter.drawLine(QPointF(17, 9), QPointF(17, 15)); | |||
| painter.drawLine(QPointF(14, 12), QPointF(20, 12)); | |||
| break; | |||
| } | |||
| case UiIcon::ParallelBranch: | |||
| { | |||
| painter.drawLine(QPointF(2, 12), QPointF(6, 12)); | |||
| @@ -28,6 +28,8 @@ enum class UiIcon | |||
| AlarmSettings, | |||
| Delete, | |||
| AddRung, | |||
| HorizontalWire, | |||
| VerticalWire, | |||
| ParallelBranch, | |||
| NormallyOpenContact, | |||
| NormallyClosedContact, | |||
| @@ -603,6 +603,26 @@ void testLadderLogicBoundaries() | |||
| 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 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"); | |||
| @@ -132,6 +132,66 @@ void testRangeParallelInsertion() | |||
| "a non-contiguous selection must be rejected"); | |||
| } | |||
| 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 = service.firstRungId(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-1", | |||
| "a wire id must become reusable after its expression is replaced"); | |||
| 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->kind == ConditionExpressionKind::Series | |||
| && rung->condition->children.size() == 3U, | |||
| "removing a bypass branch must normalize back to the original series"); | |||
| require(service.undo().succeeded | |||
| && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, | |||
| "wire branch deletion must participate in ladder undo history"); | |||
| } | |||
| void testLogicLifecycleAndOrdering() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -305,6 +365,7 @@ int main() | |||
| { | |||
| testStructuredEditingAndNormalization(); | |||
| testRangeParallelInsertion(); | |||
| testStructuredWireEditing(); | |||
| testLogicLifecycleAndOrdering(); | |||
| testEdgeTimerNodesAndRungComments(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| @@ -670,6 +670,14 @@ void testModeActionsControlEditingAvailability() | |||
| window, "addNormalCoilAction"); | |||
| QAction *parallel_insert_action = requiredChild<QAction>( | |||
| window, "parallelInsertAction"); | |||
| QAction *insert_horizontal_wire_action = requiredChild<QAction>( | |||
| window, "insertHorizontalWireAction"); | |||
| QAction *insert_vertical_wire_action = requiredChild<QAction>( | |||
| window, "insertVerticalWireAction"); | |||
| QAction *delete_horizontal_wire_action = requiredChild<QAction>( | |||
| window, "deleteHorizontalWireAction"); | |||
| QAction *delete_vertical_wire_action = requiredChild<QAction>( | |||
| window, "deleteVerticalWireAction"); | |||
| QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock"); | |||
| QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | |||
| QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | |||
| @@ -725,6 +733,10 @@ void testModeActionsControlEditingAvailability() | |||
| "configureAlarmsAction", | |||
| "deleteControlAction", | |||
| "addRungAction", | |||
| "insertHorizontalWireAction", | |||
| "insertVerticalWireAction", | |||
| "deleteHorizontalWireAction", | |||
| "deleteVerticalWireAction", | |||
| "parallelInsertAction", | |||
| "addNormallyOpenAction", | |||
| "addNormallyClosedAction", | |||
| @@ -799,7 +811,15 @@ void testModeActionsControlEditingAvailability() | |||
| require(editing_action->isChecked(), "editing action must be selected initially"); | |||
| require(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z) | |||
| && redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y) | |||
| && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete), | |||
| && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete) | |||
| && insert_horizontal_wire_action->shortcut() | |||
| == QKeySequence(Qt::Key_F11) | |||
| && insert_vertical_wire_action->shortcut() | |||
| == QKeySequence(Qt::Key_F12) | |||
| && delete_horizontal_wire_action->shortcut() | |||
| == QKeySequence(Qt::SHIFT | Qt::Key_F11) | |||
| && delete_vertical_wire_action->shortcut() | |||
| == QKeySequence(Qt::SHIFT | Qt::Key_F12), | |||
| "core editor actions must expose the agreed keyboard shortcuts"); | |||
| require(project_dock->isEnabled(), "project dock must be enabled while editing"); | |||
| require(properties_dock->isEnabled(), "properties dock must be enabled while editing"); | |||
| @@ -941,7 +961,54 @@ void testModeActionsControlEditingAvailability() | |||
| collectConditionNodes(*restored_rung.condition, &restored_nodes); | |||
| require(restored_nodes.size() == 1U, | |||
| "logic redo must restore the original condition node"); | |||
| logic_editor->selectNode(restored_nodes.front()->id); | |||
| const std::string restored_node_id = restored_nodes.front()->id; | |||
| logic_editor->selectNode(restored_node_id); | |||
| insert_horizontal_wire_action->trigger(); | |||
| const LadderRung &wired_rung = logic_editor_service.findLogic( | |||
| logic_editor_service.firstLogicId())->rungs.front(); | |||
| require(wired_rung.condition->kind == ConditionExpressionKind::Series | |||
| && wired_rung.condition->children.at(1).kind | |||
| == ConditionExpressionKind::Wire, | |||
| "F11 must insert a selectable horizontal wire after the current condition"); | |||
| delete_horizontal_wire_action->trigger(); | |||
| require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) | |||
| ->rungs.front().condition->kind == ConditionExpressionKind::Node, | |||
| "Shift+F11 must delete the selected horizontal wire"); | |||
| logic_editor->selectNode(restored_node_id); | |||
| insert_horizontal_wire_action->trigger(); | |||
| add_normally_open_action->trigger(); | |||
| const LadderRung &replaced_wire_rung = logic_editor_service.findLogic( | |||
| logic_editor_service.firstLogicId())->rungs.front(); | |||
| require(replaced_wire_rung.condition->kind == ConditionExpressionKind::Series | |||
| && replaced_wire_rung.condition->children.at(1).kind | |||
| == ConditionExpressionKind::Node, | |||
| "adding a contact on a selected wire must replace the wire in place"); | |||
| undo_action->trigger(); | |||
| undo_action->trigger(); | |||
| logic_editor->selectNode(restored_node_id); | |||
| insert_vertical_wire_action->trigger(); | |||
| const LadderRung &vertical_rung = logic_editor_service.findLogic( | |||
| logic_editor_service.firstLogicId())->rungs.front(); | |||
| require(vertical_rung.condition->kind == ConditionExpressionKind::Parallel | |||
| && vertical_rung.condition->children.at(1).kind | |||
| == ConditionExpressionKind::Wire, | |||
| "F12 must create a structured wire bypass with vertical connectors"); | |||
| logic_editor->scene()->clearSelection(); | |||
| for (QGraphicsItem *item : logic_editor->scene()->items()) | |||
| { | |||
| if (qFuzzyCompare(item->zValue(), 2.5)) | |||
| { | |||
| item->setSelected(true); | |||
| } | |||
| } | |||
| delete_vertical_wire_action->trigger(); | |||
| require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) | |||
| ->rungs.front().condition->kind == ConditionExpressionKind::Node, | |||
| "Shift+F12 must remove the selected vertical connection branch"); | |||
| logic_editor->selectNode(restored_node_id); | |||
| parallel_insert_action->trigger(); | |||
| add_normal_coil_action->trigger(); | |||
| const ControlLogic *logic = logic_editor_service.findLogic( | |||
| @@ -257,6 +257,44 @@ void testNestedSeriesParallelExpression() | |||
| require(readBit(repository, 10), "A branch must independently energize output"); | |||
| } | |||
| 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))}; | |||
| LadderRung wired_rung; | |||
| wired_rung.id = "wired-rung"; | |||
| wired_rung.name = "wired-rung"; | |||
| wired_rung.condition = root; | |||
| wired_rung.output = coil("wired-coil", 10); | |||
| const ControlLogic program = logic({wired_rung}); | |||
| writeBit(repository, 1, true); | |||
| LogicTraceSnapshot trace; | |||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||
| "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"), | |||
| "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"), | |||
| "a powered horizontal wire must pass current to the downstream contact"); | |||
| } | |||
| void testSeriesParallelContactsAndSequentialVisibility() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| @@ -862,6 +900,7 @@ int main(int argc, char *argv[]) | |||
| { | |||
| testSeriesParallelContactsAndSequentialVisibility(); | |||
| testNestedSeriesParallelExpression(); | |||
| testWirePassThroughAndPowerTrace(); | |||
| testAllComparisons(); | |||
| testSetResetAndDisabledLogic(); | |||
| testMultipleLogicScanOrderAndTraceIsolation(); | |||
| @@ -148,6 +148,7 @@ Project makeExampleProject() | |||
| series.kind = ConditionExpressionKind::Series; | |||
| series.children = { | |||
| std::move(parallel), | |||
| ConditionExpression::fromWire("start-wire", 2), | |||
| ConditionExpression::fromNode(compare)}; | |||
| rung.condition = std::move(series); | |||
| rung.output = coil; | |||
| @@ -496,6 +497,9 @@ void testExampleProjectRoundTrip() | |||
| == 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"); | |||
| @@ -553,7 +557,7 @@ void testExampleProjectRoundTrip() | |||
| "ADD and SUB operations must survive round trip"); | |||
| const auto &compare = std::get<CompareNodeConfig>( | |||
| rung.condition->children.at(1).node->config); | |||
| 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, | |||