| @@ -29,7 +29,7 @@ SOURCES += \ | |||
| src/ui/property_panel_controller.cpp \ | |||
| src/ui/runtime_panel_controller.cpp \ | |||
| src/domain/register_address.cpp \ | |||
| src/domain/register_repository.cpp \ | |||
| src/domain/virtual_register_repository.cpp \ | |||
| src/domain/active_register_repository.cpp \ | |||
| src/domain/register_monitor_model.cpp \ | |||
| src/domain/alarm_model.cpp \ | |||
| @@ -72,6 +72,7 @@ HEADERS += \ | |||
| src/ui/runtime_panel_controller.h \ | |||
| src/domain/register_address.h \ | |||
| src/domain/register_repository.h \ | |||
| src/domain/virtual_register_repository.h \ | |||
| src/domain/active_register_repository.h \ | |||
| src/domain/register_monitor_model.h \ | |||
| src/domain/alarm_model.h \ | |||
| @@ -2,8 +2,6 @@ | |||
| #include "register_address.h" | |||
| #include <array> | |||
| #include <cstddef> | |||
| #include <cstdint> | |||
| // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 | |||
| @@ -56,29 +54,3 @@ public: | |||
| virtual RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) = 0; | |||
| }; | |||
| // 基于内存的离线 M/D 寄存器仓库;地址范围与真实 PLC 保持一致 | |||
| class VirtualRegisterRepository : public RegisterRepository | |||
| { | |||
| public: | |||
| VirtualRegisterRepository(); | |||
| // 读取内存中的 M 区位值 | |||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||
| // 更新内存中的 M 区位值 | |||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||
| // 读取内存中的 D 区字值 | |||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||
| // 更新内存中的 D 区字值 | |||
| RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) override; | |||
| // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 | |||
| void clear(); | |||
| private: | |||
| static constexpr std::size_t kRegisterCount = | |||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||
| std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存 | |||
| std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存 | |||
| }; | |||
| @@ -1,4 +1,4 @@ | |||
| #include "register_repository.h" | |||
| #include "virtual_register_repository.h" | |||
| VirtualRegisterRepository::VirtualRegisterRepository() | |||
| { | |||
| @@ -0,0 +1,32 @@ | |||
| #pragma once | |||
| #include "register_repository.h" | |||
| #include <array> | |||
| #include <cstddef> | |||
| // 基于内存的离线 M/D 寄存器仓库;地址范围与真实 PLC 保持一致 | |||
| class VirtualRegisterRepository : public RegisterRepository | |||
| { | |||
| public: | |||
| VirtualRegisterRepository(); | |||
| // 读取内存中的 M 区位值 | |||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||
| // 更新内存中的 M 区位值 | |||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||
| // 读取内存中的 D 区字值 | |||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||
| // 更新内存中的 D 区字值 | |||
| RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) override; | |||
| // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 | |||
| void clear(); | |||
| private: | |||
| static constexpr std::size_t kRegisterCount = | |||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||
| std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存 | |||
| std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存 | |||
| }; | |||
| @@ -12,6 +12,7 @@ | |||
| #include "infrastructure/plc_communication_service.h" | |||
| #include "infrastructure/plc_register_repository.h" | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/alarm_editor_service.h" | |||
| @@ -259,6 +259,205 @@ bool addParallelForSelection( | |||
| return true; | |||
| } | |||
| 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) | |||
| { | |||
| 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) | |||
| { | |||
| return false; | |||
| } | |||
| for (ConditionExpression &child : expression->children) | |||
| { | |||
| if (addParallelForWireCellRange( | |||
| &child, | |||
| wire_id, | |||
| first_column, | |||
| selected_columns, | |||
| branch, | |||
| parallel_id, | |||
| series_id, | |||
| leading_wire_id, | |||
| trailing_wire_id)) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| 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) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Node | |||
| || expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return false; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Series) | |||
| { | |||
| int child_start = 0; | |||
| for (std::size_t index = 0; index < expression.children.size(); ++index) | |||
| { | |||
| const ConditionExpression &child = expression.children[index]; | |||
| if (child.kind == ConditionExpressionKind::Wire | |||
| && child.id == wire_id) | |||
| { | |||
| if (location != nullptr) | |||
| { | |||
| *location = {&expression, index, child_start}; | |||
| } | |||
| return true; | |||
| } | |||
| if (findWireCellLocation(child, wire_id, location)) | |||
| { | |||
| return true; | |||
| } | |||
| child_start += expressionColumns(child); | |||
| } | |||
| return false; | |||
| } | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| if (findWireCellLocation(child, wire_id, location)) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| 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) | |||
| { | |||
| 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) | |||
| { | |||
| return false; | |||
| } | |||
| for (std::size_t index = first_child; index <= last_child; ++index) | |||
| { | |||
| if (series->children[index].kind != ConditionExpressionKind::Wire) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| 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<ConditionExpression> 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) | |||
| { | |||
| replacement.push_back(std::move(series->children[index])); | |||
| } | |||
| series->children = std::move(replacement); | |||
| return true; | |||
| } | |||
| bool removeExpressionRecursive( | |||
| ConditionExpression *expression, const std::string &expression_id) | |||
| { | |||
| @@ -1636,6 +1835,284 @@ LogicEditorResult LogicEditorService::addParallelWireBranch( | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addParallelWireBranchAtCells( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::pair<std::string, int>> &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()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "建立竖线连接前必须选择横线网格"); | |||
| } | |||
| struct SelectedWireCell | |||
| { | |||
| std::string wire_id; | |||
| int offset = 0; | |||
| int absolute_column = 0; | |||
| }; | |||
| std::vector<SelectedWireCell> cells; | |||
| cells.reserve(selected_wire_cells.size()); | |||
| for (const auto &cell : selected_wire_cells) | |||
| { | |||
| if (cell.first.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, | |||
| "竖线网格列偏移超出横线范围"); | |||
| } | |||
| 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) | |||
| { | |||
| std::vector<int> offsets; | |||
| offsets.reserve(cells.size()); | |||
| for (const SelectedWireCell &cell : cells) | |||
| { | |||
| offsets.push_back(cell.offset); | |||
| } | |||
| std::sort(offsets.begin(), offsets.end()); | |||
| if (std::adjacent_find(offsets.cbegin(), offsets.cend()) | |||
| != offsets.cend() | |||
| || offsets.back() - offsets.front() + 1 | |||
| != static_cast<int>(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<int>(offsets.size()); | |||
| std::sort( | |||
| cells.begin(), cells.end(), | |||
| [](const SelectedWireCell &left, const SelectedWireCell &right) | |||
| { | |||
| return left.absolute_column < right.absolute_column; | |||
| }); | |||
| } | |||
| else | |||
| { | |||
| const ConditionExpression *source_series = nullptr; | |||
| for (SelectedWireCell &cell : cells) | |||
| { | |||
| 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) | |||
| { | |||
| return left.absolute_column == right.absolute_column; | |||
| }) != cells.cend() | |||
| || cells.back().absolute_column - cells.front().absolute_column + 1 | |||
| != static_cast<int>(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) | |||
| { | |||
| if (child.kind != ConditionExpressionKind::Wire) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "竖线网格不能跨越触点或并联支路"); | |||
| } | |||
| if (first_child == source_series->children.size()) | |||
| { | |||
| first_child = index; | |||
| } | |||
| last_child = index; | |||
| } | |||
| child_start = child_end; | |||
| } | |||
| if (first_child == source_series->children.size()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "未找到连续的横线网格范围"); | |||
| } | |||
| 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<int>(cells.size()); | |||
| std::unordered_set<std::string> generated_wire_ids; | |||
| const auto makeGeneratedWireId = [&]() | |||
| { | |||
| 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"; | |||
| } | |||
| 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 | |||
| { | |||
| 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) | |||
| { | |||
| rollbackEdit(std::move(before), modified_before); | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "竖线连接只能围绕同一条横线中的连续网格建立"); | |||
| } | |||
| normalizeConditionExpression(&rung->condition); | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| rollbackEdit(std::move(before), modified_before); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, branch_wire_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::setOutput( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -4,6 +4,7 @@ | |||
| #include "editor_history.h" | |||
| #include <string> | |||
| #include <utility> | |||
| #include <vector> | |||
| class ProjectService; | |||
| @@ -121,6 +122,11 @@ public: | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_expression_ids); | |||
| // 按选中的横线网格建立旁路;网格必须来自同一条横线且列号连续 | |||
| LogicEditorResult addParallelWireBranchAtCells( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::pair<std::string, int>> &selected_wire_cells); | |||
| // 输出槽编辑;每个网络最多一个输出节点 | |||
| LogicEditorResult setOutput( | |||
| const std::string &logic_id, | |||
| @@ -1,5 +1,6 @@ | |||
| #pragma once | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "software_logic_executor.h" | |||
| #include <QObject> | |||
| @@ -1618,6 +1618,10 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) | |||
| LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &config) | |||
| { | |||
| const std::string rung_id = selectedRungId(); | |||
| const std::vector<std::pair<std::string, int>> selected_empty_slots = | |||
| selectedEmptySlots(); | |||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | |||
| selectedWireCells(); | |||
| const std::vector<std::string> selected_ids = selectedNodeIds(); | |||
| std::vector<std::string> condition_ids; | |||
| for (const std::string &node_id : selected_ids) | |||
| @@ -1629,7 +1633,23 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co | |||
| } | |||
| } | |||
| LogicEditorResult result; | |||
| if (rung_id.empty() || condition_ids.empty()) | |||
| 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, | |||
| "请在同一网络中选择要并联的连续节点", {}}; | |||
| @@ -1686,13 +1706,39 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire() | |||
| LogicEditorResult LogicEditorWidget::addVerticalWire() | |||
| { | |||
| const std::string rung_id = selectedRungId(); | |||
| const std::vector<std::pair<std::string, int>> selected_empty_slots = | |||
| selectedEmptySlots(); | |||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | |||
| selectedWireCells(); | |||
| const std::vector<std::string> selected_nodes = selectedNodeIds(); | |||
| const std::vector<std::string> selected_ids = selectedExpressionIds(); | |||
| LogicEditorResult result; | |||
| if (rung_id.empty() || selected_ids.empty()) | |||
| 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 | |||
| { | |||
| result = editor_service_.addParallelWireBranch( | |||
| @@ -1,5 +1,6 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/alarm_editor_service.h" | |||
| #include "services/alarm_service.h" | |||
| #include "services/project_service.h" | |||
| @@ -6,6 +6,7 @@ | |||
| #include "domain/register_address.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/runtime_state.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "support/test_support.h" | |||
| #include <cstdint> | |||
| @@ -1,5 +1,6 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/hmi_navigation_service.h" | |||
| @@ -343,6 +343,91 @@ void testStructuredWireEditing() | |||
| "wire branch deletion must participate in ladder undo history"); | |||
| } | |||
| 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, | |||
| 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"); | |||
| } | |||
| void testWireCellParallelSelectionAcrossAdjacentWires() | |||
| { | |||
| 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<std::string> wire_ids; | |||
| for (int index = 0; index < 3; ++index) | |||
| { | |||
| 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); | |||
| } | |||
| 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"); | |||
| } | |||
| void testBatchDeleteAllNodesInParallelBranch() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -951,6 +1036,8 @@ int main() | |||
| testRangeParallelInsertion(); | |||
| testParallelBranchGridInsertion(); | |||
| testStructuredWireEditing(); | |||
| testWireCellParallelSelectionUsesExactColumns(); | |||
| testWireCellParallelSelectionAcrossAdjacentWires(); | |||
| testBatchDeleteAllNodesInParallelBranch(); | |||
| testConditionColumnLimit(); | |||
| testUnconditionalOutputEditing(); | |||
| @@ -2,6 +2,7 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/project_limits.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/alarm_editor_service.h" | |||
| #include "services/alarm_service.h" | |||
| #include "services/hmi_editor_service.h" | |||
| @@ -903,6 +904,175 @@ void testLogicParallelPaddingGridInsertion() | |||
| "using a distant branch cell must retain the skipped cell as a replaceable wire"); | |||
| } | |||
| void testLogicParallelActionsRespectGridSelectionType() | |||
| { | |||
| 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<std::string> top_ids; | |||
| for (int address = 0; address < 3; ++address) | |||
| { | |||
| const LogicEditorResult result = service.appendCondition( | |||
| logic_id, rung_id, logicContact(address)); | |||
| require(result.succeeded, | |||
| "grid selection type setup must create the upper branch"); | |||
| top_ids.push_back(result.id); | |||
| } | |||
| require(service.addParallelBranch( | |||
| logic_id, rung_id, top_ids, logicContact(10)) | |||
| .succeeded, | |||
| "grid selection type setup must create a short parallel branch"); | |||
| LogicEditorWidget editor(service); | |||
| editor.setLogicId(logic_id); | |||
| editor.resize(1400, 480); | |||
| editor.show(); | |||
| QApplication::processEvents(); | |||
| QList<QGraphicsItem *> branch_slots; | |||
| for (QGraphicsItem *item : editor.scene()->items()) | |||
| { | |||
| if (item->toolTip().startsWith(QStringLiteral("并联空网格"))) | |||
| { | |||
| branch_slots.push_back(item); | |||
| } | |||
| } | |||
| std::sort( | |||
| branch_slots.begin(), branch_slots.end(), | |||
| [](const QGraphicsItem *left, const QGraphicsItem *right) | |||
| { | |||
| return left->scenePos().x() < right->scenePos().x(); | |||
| }); | |||
| require(branch_slots.size() == 2, | |||
| "empty parallel branch must expose its two grid cells"); | |||
| branch_slots.at(0)->setSelected(true); | |||
| branch_slots.at(1)->setSelected(true); | |||
| service.clearHistory(); | |||
| const LogicEditorResult parallel = editor.addParallelBranch(logicContact(20)); | |||
| require(!parallel.succeeded | |||
| && parallel.message.find("空白网格没有可并联的逻辑") | |||
| != std::string::npos | |||
| && !service.canUndo(), | |||
| "parallel insertion must reject a selection made only of empty branch cells"); | |||
| const LogicEditorResult vertical = editor.addVerticalWire(); | |||
| require(!vertical.succeeded | |||
| && vertical.message.find("空白网格没有可连接的逻辑") | |||
| != std::string::npos | |||
| && !service.canUndo(), | |||
| "vertical insertion must reject a selection made only of empty branch cells"); | |||
| } | |||
| void testLogicVerticalWireUsesSelectedWireCells() | |||
| { | |||
| 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 UI setup must create a three-column wire"); | |||
| LogicEditorWidget editor(service); | |||
| editor.setLogicId(logic_id); | |||
| editor.resize(1400, 360); | |||
| editor.show(); | |||
| QApplication::processEvents(); | |||
| QList<QGraphicsItem *> wire_cells; | |||
| for (QGraphicsItem *item : editor.scene()->items()) | |||
| { | |||
| if (item->toolTip().startsWith(QStringLiteral("横线网格"))) | |||
| { | |||
| wire_cells.push_back(item); | |||
| } | |||
| } | |||
| std::sort( | |||
| wire_cells.begin(), wire_cells.end(), | |||
| [](const QGraphicsItem *left, const QGraphicsItem *right) | |||
| { | |||
| return left->scenePos().x() < right->scenePos().x(); | |||
| }); | |||
| require(wire_cells.size() == 3, | |||
| "a three-column wire must expose three selectable cells"); | |||
| wire_cells.at(0)->setSelected(true); | |||
| wire_cells.at(1)->setSelected(true); | |||
| const LogicEditorResult branch = editor.addVerticalWire(); | |||
| require(branch.succeeded, | |||
| "vertical insertion must accept two selected wire 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.front().kind | |||
| == ConditionExpressionKind::Parallel | |||
| && rung->condition->children.front().children.back().kind | |||
| == ConditionExpressionKind::Wire | |||
| && rung->condition->children.front().children.back() | |||
| .wire->columnSpan == 2 | |||
| && rung->condition->children.back().kind | |||
| == ConditionExpressionKind::Wire | |||
| && rung->condition->children.back().wire->columnSpan == 1, | |||
| "vertical insertion must persist exactly the selected two wire columns"); | |||
| } | |||
| void testLogicVerticalWireUsesAdjacentWireCells() | |||
| { | |||
| 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 < 3; ++index) | |||
| { | |||
| require(service.appendWire(logic_id, rung_id).succeeded, | |||
| "adjacent wire UI setup must create one-column segments"); | |||
| } | |||
| LogicEditorWidget editor(service); | |||
| editor.setLogicId(logic_id); | |||
| editor.resize(1400, 360); | |||
| editor.show(); | |||
| QApplication::processEvents(); | |||
| QList<QGraphicsItem *> wire_cells; | |||
| for (QGraphicsItem *item : editor.scene()->items()) | |||
| { | |||
| if (item->toolTip().startsWith(QStringLiteral("横线网格"))) | |||
| { | |||
| wire_cells.push_back(item); | |||
| } | |||
| } | |||
| std::sort( | |||
| wire_cells.begin(), wire_cells.end(), | |||
| [](const QGraphicsItem *left, const QGraphicsItem *right) | |||
| { | |||
| return left->scenePos().x() < right->scenePos().x(); | |||
| }); | |||
| require(wire_cells.size() == 3, | |||
| "adjacent one-column wires must expose three selectable cells"); | |||
| for (QGraphicsItem *cell : wire_cells) | |||
| { | |||
| cell->setSelected(true); | |||
| } | |||
| const LogicEditorResult branch = editor.addVerticalWire(); | |||
| require(branch.succeeded, | |||
| "vertical insertion must accept adjacent cells from separate wire expressions"); | |||
| 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().wire->columnSpan == 3 | |||
| && rung->condition->children.back().wire->columnSpan == 3, | |||
| "adjacent wire cells must persist a three-column bypass in the UI path"); | |||
| } | |||
| void testLogicParallelBranchConnectsShortBranchToRightJoin() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -2477,6 +2647,9 @@ int main(int argc, char *argv[]) | |||
| testRuntimeAlarmListInteraction(); | |||
| testLogicEmptyGridSlotInsertion(); | |||
| testLogicParallelPaddingGridInsertion(); | |||
| testLogicParallelActionsRespectGridSelectionType(); | |||
| testLogicVerticalWireUsesSelectedWireCells(); | |||
| testLogicVerticalWireUsesAdjacentWireCells(); | |||
| testLogicParallelBranchConnectsShortBranchToRightJoin(); | |||
| testLogicContinuousInsertionConsumesFullWire(); | |||
| testLogicHorizontalWireCanBeInsertedAgainAfterUndoingFirstAutoRung(); | |||
| @@ -1,5 +1,6 @@ | |||
| #include "domain/hmi_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/software_logic_executor.h" | |||
| @@ -1,5 +1,6 @@ | |||
| #include "domain/control_logic_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/software_logic_executor.h" | |||
| #include <QTest> | |||
| @@ -5,6 +5,7 @@ | |||
| #include "infrastructure/plc_communication_service.h" | |||
| #include "support/test_support.h" | |||
| #include "infrastructure/plc_register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| #include "services/project_service.h" | |||
| @@ -2,11 +2,12 @@ | |||
| DOMAIN_REGISTER_SOURCES = \ | |||
| ../src/domain/register_address.cpp \ | |||
| ../src/domain/register_repository.cpp | |||
| ../src/domain/virtual_register_repository.cpp | |||
| DOMAIN_REGISTER_HEADERS = \ | |||
| ../src/domain/register_address.h \ | |||
| ../src/domain/register_repository.h | |||
| ../src/domain/register_repository.h \ | |||
| ../src/domain/virtual_register_repository.h | |||
| DOMAIN_HMI_SOURCES = \ | |||
| ../src/domain/hmi_model.cpp \ | |||
| @@ -1,6 +1,7 @@ | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/register_monitor_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/register_monitor_service.h" | |||
| #include "support/test_support.h" | |||
| @@ -4,6 +4,7 @@ | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "support/test_support.h" | |||
| #include <functional> | |||