| @@ -45,6 +45,7 @@ SOURCES += \ | |||
| src/services/hmi_editor_service.cpp \ | |||
| src/services/hmi_navigation_service.cpp \ | |||
| src/services/logic_editor_service.cpp \ | |||
| src/services/logic_command_service.cpp \ | |||
| src/services/hmi_runtime_service.cpp \ | |||
| src/services/software_logic_executor.cpp \ | |||
| src/services/offline_simulation_service.cpp \ | |||
| @@ -96,6 +97,7 @@ HEADERS += \ | |||
| src/services/hmi_editor_service.h \ | |||
| src/services/hmi_navigation_service.h \ | |||
| src/services/logic_editor_service.h \ | |||
| src/services/logic_command_service.h \ | |||
| src/services/hmi_runtime_service.h \ | |||
| src/services/software_logic_executor.h \ | |||
| src/services/offline_simulation_service.h \ | |||
| @@ -0,0 +1,590 @@ | |||
| #include "logic_command_service.h" | |||
| #include "logic_editor_service.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <limits> | |||
| #include <sstream> | |||
| #include <unordered_map> | |||
| namespace { | |||
| std::vector<std::string> splitTokens(const std::string &text) | |||
| { | |||
| std::istringstream stream(text); | |||
| std::vector<std::string> tokens; | |||
| std::string token; | |||
| while (stream >> token) | |||
| { | |||
| tokens.push_back(std::move(token)); | |||
| } | |||
| return tokens; | |||
| } | |||
| std::string upperAscii(std::string value) | |||
| { | |||
| std::transform( | |||
| value.begin(), value.end(), value.begin(), | |||
| [](unsigned char character) | |||
| { | |||
| return static_cast<char>(std::toupper(character)); | |||
| }); | |||
| return value; | |||
| } | |||
| LogicCommandParseResult parseFailure(const std::string &message) | |||
| { | |||
| return {false, message, {}}; | |||
| } | |||
| bool requiresOperands( | |||
| const std::vector<std::string> &tokens, | |||
| std::size_t count, | |||
| const std::string &example, | |||
| LogicCommandParseResult *failure) | |||
| { | |||
| if (tokens.size() == count + 1U) | |||
| { | |||
| return true; | |||
| } | |||
| *failure = parseFailure( | |||
| tokens.front() + " 需要 " + std::to_string(count) | |||
| + " 个操作数,例如 " + example); | |||
| return false; | |||
| } | |||
| bool parseAddress( | |||
| const std::string &text, | |||
| RegisterArea expected_area, | |||
| const std::string &instruction, | |||
| RegisterAddress *address, | |||
| LogicCommandParseResult *failure) | |||
| { | |||
| const RegisterAddressParseResult parsed = parseRegisterAddress(text); | |||
| const char expected_prefix = expected_area == RegisterArea::M ? 'M' : 'D'; | |||
| if (!parsed.succeeded) | |||
| { | |||
| const std::string range = std::string(1, expected_prefix) + "0~" | |||
| + expected_prefix + std::to_string(RegisterAddress::kMaximumIndex); | |||
| *failure = parseFailure( | |||
| parsed.error == RegisterAddressParseError::OutOfRange | |||
| ? std::string(1, expected_prefix) + " 地址超出范围,应为 " + range | |||
| : "地址格式错误,应为 " + range); | |||
| return false; | |||
| } | |||
| if (parsed.address.area() != expected_area) | |||
| { | |||
| *failure = parseFailure( | |||
| instruction + " 只支持 " + std::string(1, expected_prefix) + " 地址"); | |||
| return false; | |||
| } | |||
| *address = parsed.address; | |||
| return true; | |||
| } | |||
| bool parseInt16( | |||
| const std::string &text, | |||
| const std::string &instruction, | |||
| std::int16_t *value, | |||
| LogicCommandParseResult *failure); | |||
| bool parseWordOperand( | |||
| const std::string &text, | |||
| const std::string &instruction, | |||
| WordOperand *operand, | |||
| LogicCommandParseResult *failure) | |||
| { | |||
| const RegisterAddressParseResult parsed = parseRegisterAddress(text); | |||
| if (parsed.succeeded) | |||
| { | |||
| if (parsed.address.area() != RegisterArea::D) | |||
| { | |||
| *failure = parseFailure(instruction + " 的寄存器操作数必须使用 D 地址"); | |||
| return false; | |||
| } | |||
| *operand = WordOperand{ | |||
| WordOperandKind::Register, parsed.address, 0}; | |||
| return true; | |||
| } | |||
| if (!text.empty() | |||
| && (text.front() == 'D' || text.front() == 'd' | |||
| || text.front() == 'M' || text.front() == 'm')) | |||
| { | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| return parseAddress( | |||
| text, RegisterArea::D, instruction, &address, failure) | |||
| && ((*operand = WordOperand{ | |||
| WordOperandKind::Register, address, 0}), true); | |||
| } | |||
| std::int16_t constant = 0; | |||
| if (!parseInt16(text, instruction, &constant, failure)) | |||
| { | |||
| return false; | |||
| } | |||
| *operand = WordOperand{ | |||
| WordOperandKind::Constant, | |||
| RegisterAddress{RegisterArea::D, 0}, | |||
| constant}; | |||
| return true; | |||
| } | |||
| bool isLoad(LogicCommandOpcode opcode) | |||
| { | |||
| return opcode == LogicCommandOpcode::Load | |||
| || opcode == LogicCommandOpcode::LoadInverse | |||
| || opcode == LogicCommandOpcode::LoadRising | |||
| || opcode == LogicCommandOpcode::LoadFalling | |||
| || opcode == LogicCommandOpcode::CompareEqual | |||
| || opcode == LogicCommandOpcode::CompareNotEqual | |||
| || opcode == LogicCommandOpcode::CompareLessThan | |||
| || opcode == LogicCommandOpcode::CompareLessThanOrEqual | |||
| || opcode == LogicCommandOpcode::CompareGreaterThan | |||
| || opcode == LogicCommandOpcode::CompareGreaterThanOrEqual; | |||
| } | |||
| bool isOr(LogicCommandOpcode opcode) | |||
| { | |||
| return opcode == LogicCommandOpcode::Or | |||
| || opcode == LogicCommandOpcode::OrInverse; | |||
| } | |||
| bool isCondition(LogicCommandOpcode opcode) | |||
| { | |||
| return isLoad(opcode) || isOr(opcode) | |||
| || opcode == LogicCommandOpcode::And | |||
| || opcode == LogicCommandOpcode::AndInverse; | |||
| } | |||
| bool isConditionTarget(LogicCommandTargetKind kind) | |||
| { | |||
| return kind != LogicCommandTargetKind::Output; | |||
| } | |||
| bool isOutput(LogicCommandOpcode opcode) | |||
| { | |||
| return opcode == LogicCommandOpcode::Output | |||
| || opcode == LogicCommandOpcode::Set | |||
| || opcode == LogicCommandOpcode::Reset | |||
| || opcode == LogicCommandOpcode::Move | |||
| || opcode == LogicCommandOpcode::Add | |||
| || opcode == LogicCommandOpcode::Subtract; | |||
| } | |||
| bool parseInt16( | |||
| const std::string &text, | |||
| const std::string &instruction, | |||
| std::int16_t *value, | |||
| LogicCommandParseResult *failure) | |||
| { | |||
| try | |||
| { | |||
| std::size_t consumed = 0; | |||
| const long parsed = std::stol(text, &consumed, 10); | |||
| if (consumed != text.size() | |||
| || parsed < std::numeric_limits<std::int16_t>::min() | |||
| || parsed > std::numeric_limits<std::int16_t>::max()) | |||
| { | |||
| throw std::out_of_range("int16"); | |||
| } | |||
| *value = static_cast<std::int16_t>(parsed); | |||
| return true; | |||
| } | |||
| catch (const std::exception &) | |||
| { | |||
| *failure = parseFailure( | |||
| instruction + " 的第二个操作数应为 -32768~32767 的常量"); | |||
| return false; | |||
| } | |||
| } | |||
| LogicCommandResult executionFailure( | |||
| const std::string &message, LogicCommandOpcode opcode) | |||
| { | |||
| return {false, message, {}, {}, opcode}; | |||
| } | |||
| } // namespace | |||
| LogicCommandService::LogicCommandService(LogicEditorService &editor_service) | |||
| : editor_service_(editor_service) | |||
| { | |||
| } | |||
| LogicCommandParseResult LogicCommandService::parse(const std::string &text) | |||
| { | |||
| std::vector<std::string> tokens = splitTokens(text); | |||
| if (tokens.empty()) | |||
| { | |||
| return parseFailure("请输入 PLC 指令"); | |||
| } | |||
| tokens.front() = upperAscii(tokens.front()); | |||
| const std::string &mnemonic = tokens.front(); | |||
| LogicCommandParseResult failure; | |||
| const std::unordered_map<std::string, LogicCommandOpcode> condition_opcodes{ | |||
| {"LD", LogicCommandOpcode::Load}, | |||
| {"LDI", LogicCommandOpcode::LoadInverse}, | |||
| {"LDP", LogicCommandOpcode::LoadRising}, | |||
| {"LDF", LogicCommandOpcode::LoadFalling}, | |||
| {"AND", LogicCommandOpcode::And}, | |||
| {"ANI", LogicCommandOpcode::AndInverse}, | |||
| {"OR", LogicCommandOpcode::Or}, | |||
| {"ORI", LogicCommandOpcode::OrInverse}}; | |||
| const std::unordered_map<std::string, LogicCommandOpcode> compare_opcodes{ | |||
| {"LD=", LogicCommandOpcode::CompareEqual}, | |||
| {"LD<>", LogicCommandOpcode::CompareNotEqual}, | |||
| {"LD<", LogicCommandOpcode::CompareLessThan}, | |||
| {"LD<=", LogicCommandOpcode::CompareLessThanOrEqual}, | |||
| {"LD>", LogicCommandOpcode::CompareGreaterThan}, | |||
| {"LD>=", LogicCommandOpcode::CompareGreaterThanOrEqual}}; | |||
| const auto compare = compare_opcodes.find(mnemonic); | |||
| if (compare != compare_opcodes.end()) | |||
| { | |||
| if (!requiresOperands(tokens, 2U, mnemonic + " D0 0", &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| if (!parseAddress(tokens[1], RegisterArea::D, mnemonic, &address, &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| std::int16_t value = 0; | |||
| if (!parseInt16(tokens[2], mnemonic, &value, &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| ComparisonOperator operation = ComparisonOperator::Equal; | |||
| switch (compare->second) | |||
| { | |||
| case LogicCommandOpcode::CompareEqual: | |||
| operation = ComparisonOperator::Equal; | |||
| break; | |||
| case LogicCommandOpcode::CompareNotEqual: | |||
| operation = ComparisonOperator::NotEqual; | |||
| break; | |||
| case LogicCommandOpcode::CompareLessThan: | |||
| operation = ComparisonOperator::LessThan; | |||
| break; | |||
| case LogicCommandOpcode::CompareLessThanOrEqual: | |||
| operation = ComparisonOperator::LessThanOrEqual; | |||
| break; | |||
| case LogicCommandOpcode::CompareGreaterThan: | |||
| operation = ComparisonOperator::GreaterThan; | |||
| break; | |||
| case LogicCommandOpcode::CompareGreaterThanOrEqual: | |||
| operation = ComparisonOperator::GreaterThanOrEqual; | |||
| break; | |||
| default: | |||
| break; | |||
| } | |||
| return {true, {}, {compare->second, | |||
| CompareNodeConfig{address, operation, value}}}; | |||
| } | |||
| const auto condition = condition_opcodes.find(mnemonic); | |||
| if (condition != condition_opcodes.end()) | |||
| { | |||
| if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| LogicNodeConfig config; | |||
| if (condition->second == LogicCommandOpcode::LoadRising | |||
| || condition->second == LogicCommandOpcode::LoadFalling) | |||
| { | |||
| config = EdgeContactNodeConfig{ | |||
| address, | |||
| condition->second == LogicCommandOpcode::LoadRising | |||
| ? EdgeMode::Rising : EdgeMode::Falling}; | |||
| } | |||
| else | |||
| { | |||
| const bool inverse = condition->second == LogicCommandOpcode::LoadInverse | |||
| || condition->second == LogicCommandOpcode::AndInverse | |||
| || condition->second == LogicCommandOpcode::OrInverse; | |||
| config = ContactNodeConfig{ | |||
| address, | |||
| inverse ? ContactMode::NormallyClosed | |||
| : ContactMode::NormallyOpen}; | |||
| } | |||
| return {true, {}, {condition->second, std::move(config)}}; | |||
| } | |||
| const std::unordered_map<std::string, LogicCommandOpcode> coil_opcodes{ | |||
| {"OUT", LogicCommandOpcode::Output}, | |||
| {"SET", LogicCommandOpcode::Set}, | |||
| {"RST", LogicCommandOpcode::Reset}}; | |||
| const auto coil = coil_opcodes.find(mnemonic); | |||
| if (coil != coil_opcodes.end()) | |||
| { | |||
| if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| const CoilMode mode = coil->second == LogicCommandOpcode::Set | |||
| ? CoilMode::Set | |||
| : coil->second == LogicCommandOpcode::Reset | |||
| ? CoilMode::Reset : CoilMode::Normal; | |||
| return {true, {}, {coil->second, CoilNodeConfig{address, mode}}}; | |||
| } | |||
| if (mnemonic == "MOV") | |||
| { | |||
| if (!requiresOperands(tokens, 2U, "MOV D0 D1", &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| RegisterAddress destination{RegisterArea::D, 0}; | |||
| WordOperand source; | |||
| if (!parseWordOperand(tokens[1], mnemonic, &source, &failure) | |||
| || !parseAddress(tokens[2], RegisterArea::D, mnemonic, &destination, &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| return { | |||
| true, {}, | |||
| {LogicCommandOpcode::Move, | |||
| MoveNodeConfig{source, destination}}}; | |||
| } | |||
| if (mnemonic == "ADD" || mnemonic == "SUB") | |||
| { | |||
| if (!requiresOperands(tokens, 3U, mnemonic + " D0 D1 D2", &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| WordOperand left; | |||
| WordOperand right; | |||
| RegisterAddress destination{RegisterArea::D, 0}; | |||
| if (!parseWordOperand(tokens[1], mnemonic, &left, &failure) | |||
| || !parseWordOperand(tokens[2], mnemonic, &right, &failure) | |||
| || !parseAddress(tokens[3], RegisterArea::D, mnemonic, &destination, &failure)) | |||
| { | |||
| return failure; | |||
| } | |||
| const LogicCommandOpcode opcode = mnemonic == "ADD" | |||
| ? LogicCommandOpcode::Add : LogicCommandOpcode::Subtract; | |||
| return { | |||
| true, {}, | |||
| {opcode, | |||
| ArithmeticNodeConfig{ | |||
| opcode == LogicCommandOpcode::Add | |||
| ? ArithmeticOperation::Add : ArithmeticOperation::Subtract, | |||
| left, | |||
| right, | |||
| destination}}}; | |||
| } | |||
| return parseFailure("当前指令暂不支持:" + mnemonic); | |||
| } | |||
| const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions() | |||
| { | |||
| static const std::vector<LogicCommandSuggestion> values{ | |||
| {"LD", "常开触点", "M 地址"}, | |||
| {"LDI", "常闭触点", "M 地址"}, | |||
| {"LDP", "上升沿触点", "M 地址"}, | |||
| {"LDF", "下降沿触点", "M 地址"}, | |||
| {"LD=", "D 值等于常量", "D 地址 + 常量"}, | |||
| {"LD<>", "D 值不等于常量", "D 地址 + 常量"}, | |||
| {"LD<", "D 值小于常量", "D 地址 + 常量"}, | |||
| {"LD<=", "D 值小于等于常量", "D 地址 + 常量"}, | |||
| {"LD>", "D 值大于常量", "D 地址 + 常量"}, | |||
| {"LD>=", "D 值大于等于常量", "D 地址 + 常量"}, | |||
| {"AND", "串联常开触点", "M 地址"}, | |||
| {"ANI", "串联常闭触点", "M 地址"}, | |||
| {"OR", "并联常开触点", "M 地址"}, | |||
| {"ORI", "并联常闭触点", "M 地址"}, | |||
| {"OUT", "普通线圈", "M 地址"}, | |||
| {"SET", "置位线圈", "M 地址"}, | |||
| {"RST", "复位线圈", "M 地址"}, | |||
| {"MOV", "数据传送", "D 地址 -> D 地址"}, | |||
| {"ADD", "加法", "D 地址 + D 地址 -> D 地址"}, | |||
| {"SUB", "减法", "D 地址 - D 地址 -> D 地址"}, | |||
| {"TON", "暂不支持", "T 地址", false}, | |||
| {"CTU", "暂不支持", "C 地址", false}}; | |||
| return values; | |||
| } | |||
| LogicCommandResult LogicCommandService::execute( | |||
| const LogicCommandRequest &request) | |||
| { | |||
| const LogicCommandParseResult parsed = parse(request.text); | |||
| if (!parsed.succeeded) | |||
| { | |||
| return executionFailure(parsed.message, parsed.command.opcode); | |||
| } | |||
| const LogicCommandOpcode opcode = parsed.command.opcode; | |||
| LogicEditorResult edited; | |||
| if (request.target.kind == LogicCommandTargetKind::ExistingNode) | |||
| { | |||
| const LogicNode *existing = editor_service_.findNode( | |||
| request.logicId, request.target.expressionId); | |||
| if (existing == nullptr) | |||
| { | |||
| return executionFailure("未找到要编辑的逻辑节点", opcode); | |||
| } | |||
| if (existing->isCondition()) | |||
| { | |||
| if (!isLoad(opcode)) | |||
| { | |||
| return executionFailure( | |||
| "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); | |||
| } | |||
| } | |||
| else if (existing->isOutput()) | |||
| { | |||
| if (!isOutput(opcode)) | |||
| { | |||
| return executionFailure( | |||
| "已有输出节点只能替换为 OUT/SET/RST/MOV/ADD/SUB 指令", | |||
| opcode); | |||
| } | |||
| } | |||
| else | |||
| { | |||
| return executionFailure("当前节点类型不支持命令替换", opcode); | |||
| } | |||
| edited = editor_service_.updateNodeConfig( | |||
| request.logicId, request.target.expressionId, | |||
| parsed.command.config); | |||
| } | |||
| else if (isCondition(opcode)) | |||
| { | |||
| if (isLoad(opcode) && request.continuing) | |||
| { | |||
| edited = editor_service_.appendCondition( | |||
| request.logicId, {}, parsed.command.config, true); | |||
| } | |||
| else if (isOr(opcode)) | |||
| { | |||
| const std::string rung_id = request.continuing | |||
| ? request.currentRungId : request.target.rungId; | |||
| if (request.continuing) | |||
| { | |||
| edited = editor_service_.addParallelToWholeCondition( | |||
| request.logicId, rung_id, parsed.command.config, true); | |||
| } | |||
| else if (request.parallelNodeIds.empty()) | |||
| { | |||
| return executionFailure( | |||
| "OR/ORI 需要先选中同一网络中的连续条件", opcode); | |||
| } | |||
| else | |||
| { | |||
| edited = editor_service_.addParallelBranch( | |||
| request.logicId, | |||
| rung_id, | |||
| request.parallelNodeIds, | |||
| parsed.command.config, | |||
| true); | |||
| } | |||
| } | |||
| else if (request.continuing) | |||
| { | |||
| const LadderRung *rung = editor_service_.findRung( | |||
| request.logicId, request.currentRungId); | |||
| if (rung != nullptr && rung->output.has_value()) | |||
| { | |||
| return executionFailure( | |||
| "当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络", | |||
| opcode); | |||
| } | |||
| edited = editor_service_.appendCondition( | |||
| request.logicId, request.currentRungId, | |||
| parsed.command.config, true); | |||
| } | |||
| else | |||
| { | |||
| if (!isConditionTarget(request.target.kind)) | |||
| { | |||
| return executionFailure( | |||
| "条件指令只能输入在第 1~10 列条件区", opcode); | |||
| } | |||
| if ((opcode == LogicCommandOpcode::And | |||
| || opcode == LogicCommandOpcode::AndInverse) | |||
| && (!request.target.rungId.empty() | |||
| && (editor_service_.findRung( | |||
| request.logicId, request.target.rungId) == nullptr | |||
| || !editor_service_.findRung( | |||
| request.logicId, request.target.rungId) | |||
| ->condition.has_value()))) | |||
| { | |||
| return executionFailure( | |||
| "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); | |||
| } | |||
| switch (request.target.kind) | |||
| { | |||
| case LogicCommandTargetKind::EmptyColumn: | |||
| edited = editor_service_.insertConditionAtColumn( | |||
| request.logicId, request.target.rungId, | |||
| request.target.column, parsed.command.config, true); | |||
| break; | |||
| case LogicCommandTargetKind::BranchEmptyColumn: | |||
| edited = editor_service_.insertConditionInBranchAtColumn( | |||
| request.logicId, request.target.rungId, | |||
| request.target.expressionId, request.target.column, | |||
| parsed.command.config, true); | |||
| break; | |||
| case LogicCommandTargetKind::WireColumn: | |||
| edited = editor_service_.replaceWireColumnWithCondition( | |||
| request.logicId, request.target.rungId, | |||
| request.target.expressionId, request.target.column, | |||
| parsed.command.config, true); | |||
| break; | |||
| case LogicCommandTargetKind::GapColumn: | |||
| edited = editor_service_.replaceGapColumnWithCondition( | |||
| request.logicId, request.target.rungId, | |||
| request.target.expressionId, request.target.column, | |||
| parsed.command.config, true); | |||
| break; | |||
| case LogicCommandTargetKind::Output: | |||
| break; | |||
| case LogicCommandTargetKind::ExistingNode: | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| else | |||
| { | |||
| const std::string rung_id = request.continuing | |||
| ? request.currentRungId : request.target.rungId; | |||
| if (!request.continuing | |||
| && request.target.kind != LogicCommandTargetKind::Output) | |||
| { | |||
| return executionFailure( | |||
| "输出指令只能输入在第 11 列输出区", opcode); | |||
| } | |||
| const LadderRung *rung = editor_service_.findRung( | |||
| request.logicId, rung_id); | |||
| if (request.continuing && rung != nullptr && rung->output.has_value()) | |||
| { | |||
| return executionFailure( | |||
| "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", | |||
| opcode); | |||
| } | |||
| edited = editor_service_.setOutput( | |||
| request.logicId, rung_id, parsed.command.config, true); | |||
| } | |||
| if (!edited.succeeded) | |||
| { | |||
| return executionFailure(edited.message, opcode); | |||
| } | |||
| return { | |||
| true, {}, edited.id, | |||
| editor_service_.rungIdForNode(request.logicId, edited.id), | |||
| opcode}; | |||
| } | |||
| @@ -0,0 +1,106 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include <string> | |||
| #include <vector> | |||
| class LogicEditorService; | |||
| enum class LogicCommandOpcode | |||
| { | |||
| Load, | |||
| LoadInverse, | |||
| LoadRising, | |||
| LoadFalling, | |||
| CompareEqual, | |||
| CompareNotEqual, | |||
| CompareLessThan, | |||
| CompareLessThanOrEqual, | |||
| CompareGreaterThan, | |||
| CompareGreaterThanOrEqual, | |||
| And, | |||
| AndInverse, | |||
| Or, | |||
| OrInverse, | |||
| Output, | |||
| Set, | |||
| Reset, | |||
| Move, | |||
| Add, | |||
| Subtract | |||
| }; | |||
| struct ParsedLogicCommand | |||
| { | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||
| LogicNodeConfig config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; | |||
| }; | |||
| struct LogicCommandParseResult | |||
| { | |||
| bool succeeded = false; | |||
| std::string message; | |||
| ParsedLogicCommand command; | |||
| }; | |||
| struct LogicCommandSuggestion | |||
| { | |||
| std::string mnemonic; | |||
| std::string description; | |||
| std::string operand_hint; | |||
| bool supported = true; | |||
| }; | |||
| enum class LogicCommandTargetKind | |||
| { | |||
| EmptyColumn, | |||
| BranchEmptyColumn, | |||
| WireColumn, | |||
| GapColumn, | |||
| Output, | |||
| ExistingNode | |||
| }; | |||
| struct LogicCommandTarget | |||
| { | |||
| LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn; | |||
| std::string rungId; | |||
| std::string expressionId; | |||
| int column = 0; | |||
| }; | |||
| struct LogicCommandRequest | |||
| { | |||
| std::string logicId; | |||
| std::string text; | |||
| LogicCommandTarget target; | |||
| bool continuing = false; | |||
| std::string currentRungId; | |||
| std::vector<std::string> parallelNodeIds; | |||
| }; | |||
| struct LogicCommandResult | |||
| { | |||
| bool succeeded = false; | |||
| std::string message; | |||
| std::string id; | |||
| std::string rungId; | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||
| }; | |||
| // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 | |||
| class LogicCommandService | |||
| { | |||
| public: | |||
| explicit LogicCommandService(LogicEditorService &editor_service); | |||
| static LogicCommandParseResult parse(const std::string &text); | |||
| static const std::vector<LogicCommandSuggestion> &suggestions(); | |||
| LogicCommandResult execute(const LogicCommandRequest &request); | |||
| private: | |||
| LogicEditorService &editor_service_; | |||
| }; | |||
| @@ -68,9 +68,12 @@ std::string rungLimitMessage( | |||
| : std::string{}; | |||
| } | |||
| LogicNode makeNode(const std::string &id, const LogicNodeConfig &config) | |||
| LogicNode makeNode( | |||
| const std::string &id, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false) | |||
| { | |||
| return {id, config, false}; | |||
| return {id, config, configured}; | |||
| } | |||
| ConditionExpression makeContainer( | |||
| @@ -165,6 +168,28 @@ int expressionColumns(const ConditionExpression &expression) | |||
| return columns; | |||
| } | |||
| std::optional<std::pair<std::string, int>> firstGapCell( | |||
| const ConditionExpression &expression) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| return std::make_pair(expression.id, 0); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node | |||
| || expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| if (const auto gap = firstGapCell(child); gap.has_value()) | |||
| { | |||
| return gap; | |||
| } | |||
| } | |||
| return std::nullopt; | |||
| } | |||
| bool isSubset(const NodeIdSet &subset, const NodeIdSet &values) | |||
| { | |||
| return std::all_of( | |||
| @@ -1158,13 +1183,36 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) | |||
| LogicEditorResult LogicEditorService::removeRung( | |||
| const std::string &logic_id, const std::string &rung_id) | |||
| { | |||
| return removeRungs(logic_id, {rung_id}); | |||
| } | |||
| LogicEditorResult LogicEditorService::removeRungs( | |||
| const std::string &logic_id, | |||
| const std::vector<std::string> &rung_ids) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | |||
| } | |||
| if (findRung(logic_id, rung_id) == nullptr) | |||
| if (rung_ids.empty()) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "请先选择要删除的梯形图网络"); | |||
| } | |||
| std::unordered_set<std::string> selected_ids; | |||
| for (const std::string &rung_id : rung_ids) | |||
| { | |||
| if (!selected_ids.insert(rung_id).second) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复网络"); | |||
| } | |||
| if (findRung(logic_id, rung_id) == nullptr) | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| } | |||
| if (selected_ids.empty()) | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| @@ -1176,10 +1224,13 @@ LogicEditorResult LogicEditorService::removeRung( | |||
| target->rungs.erase( | |||
| std::remove_if( | |||
| target->rungs.begin(), target->rungs.end(), | |||
| [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }), | |||
| [&selected_ids](const LadderRung &rung) | |||
| { | |||
| return selected_ids.find(rung.id) != selected_ids.end(); | |||
| }), | |||
| target->rungs.end()); | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, rung_id}; | |||
| return {true, LogicEditorError::None, {}, rung_ids.front()}; | |||
| } | |||
| LogicEditorResult LogicEditorService::updateRungComment( | |||
| @@ -1219,7 +1270,8 @@ LogicEditorResult LogicEditorService::updateRungComment( | |||
| LogicEditorResult LogicEditorService::appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config) | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| @@ -1227,7 +1279,9 @@ LogicEditorResult LogicEditorService::appendCondition( | |||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | |||
| } | |||
| const bool create_rung = rung_id.empty(); | |||
| if (!create_rung && findRung(logic_id, rung_id) == nullptr) | |||
| const LadderRung *existing_rung = create_rung | |||
| ? nullptr : findRung(logic_id, rung_id); | |||
| if (!create_rung && existing_rung == nullptr) | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| @@ -1243,11 +1297,22 @@ LogicEditorResult LogicEditorService::appendCondition( | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "梯形图条件不能使用线圈节点"); | |||
| } | |||
| if (existing_rung != nullptr && !existing_rung->output.has_value() | |||
| && existing_rung->condition.has_value()) | |||
| { | |||
| // 普通条件追加也优先填充删除后保留的 Gap,避免把新节点追加到网络末端 | |||
| if (const auto gap = firstGapCell(*existing_rung->condition); gap.has_value()) | |||
| { | |||
| return replaceGapColumnWithCondition( | |||
| logic_id, rung_id, gap->first, gap->second, config, configured); | |||
| } | |||
| } | |||
| // 先生成稳定节点 ID,再把新节点接到已有表达式的串联末尾 | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| const std::string target_rung_id = create_rung | |||
| ? makeUniqueRungId(*logic) : rung_id; | |||
| ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | |||
| ConditionExpression leaf = ConditionExpression::fromNode( | |||
| makeNode(node_id, config, configured)); | |||
| HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| @@ -1358,7 +1423,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column, | |||
| const LogicNodeConfig &config) | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LadderRung *existing_rung = findRung(logic_id, rung_id); | |||
| @@ -1388,7 +1454,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( | |||
| // 点击远端空网格时,用持久化横线填充前置空档,再放入真实条件节点 | |||
| const int leading_wire_columns = column - occupied_columns; | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | |||
| ConditionExpression leaf = ConditionExpression::fromNode( | |||
| makeNode(node_id, config, configured)); | |||
| HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| @@ -1459,7 +1526,8 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( | |||
| const std::string &rung_id, | |||
| const std::string &branch_expression_id, | |||
| int column, | |||
| const LogicNodeConfig &config) | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LadderRung *existing_rung = findRung(logic_id, rung_id); | |||
| @@ -1503,7 +1571,7 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( | |||
| ? makeUniqueWireId(*logic) : std::string{}; | |||
| const std::string series_id = makeUniqueExpressionId(*logic); | |||
| ConditionExpression leaf = ConditionExpression::fromNode( | |||
| makeNode(node_id, config)); | |||
| makeNode(node_id, config, configured)); | |||
| HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| @@ -1572,6 +1640,16 @@ LogicEditorResult LogicEditorService::appendWire( | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| if (existing_rung != nullptr && column_span == 1 | |||
| && existing_rung->condition.has_value()) | |||
| { | |||
| // 删除后的 Gap 仍保留原网格位置;无选中目标追加时优先填回第一个 Gap | |||
| if (const auto gap = firstGapCell(*existing_rung->condition); gap.has_value()) | |||
| { | |||
| return replaceGapColumnWithWire( | |||
| logic_id, rung_id, gap->first, gap->second); | |||
| } | |||
| } | |||
| bool create_rung = rung_id.empty(); | |||
| if (!create_rung && column_span == 1 && existing_rung->condition.has_value() | |||
| && expressionColumns(*existing_rung->condition) | |||
| @@ -1762,7 +1840,25 @@ LogicEditorResult LogicEditorService::insertWireAfter( | |||
| { | |||
| return child.id == target_expression_id; | |||
| }); | |||
| parent->children.insert(target_iterator + 1, std::move(leaf)); | |||
| const auto insertion_position = target_iterator + 1; | |||
| if (insertion_position != parent->children.end() | |||
| && insertion_position->kind == ConditionExpressionKind::Gap) | |||
| { | |||
| // 连续添加横线时优先消耗目标后面的 Gap,保持网络总列数不变 | |||
| if (insertion_position->gap->columnSpan == 1) | |||
| { | |||
| *insertion_position = std::move(leaf); | |||
| } | |||
| else | |||
| { | |||
| --insertion_position->gap->columnSpan; | |||
| parent->children.insert(insertion_position, std::move(leaf)); | |||
| } | |||
| } | |||
| else | |||
| { | |||
| parent->children.insert(insertion_position, std::move(leaf)); | |||
| } | |||
| } | |||
| else | |||
| { | |||
| @@ -1830,7 +1926,8 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| int column_offset, | |||
| const LogicNodeConfig &config) | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const ConditionExpression *wire = findExpression( | |||
| @@ -1866,7 +1963,7 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( | |||
| wire_expression_id, leading_columns)); | |||
| } | |||
| replacement.children.push_back( | |||
| ConditionExpression::fromNode(makeNode(node_id, config))); | |||
| ConditionExpression::fromNode(makeNode(node_id, config, configured))); | |||
| if (trailing_columns > 0) | |||
| { | |||
| replacement.children.push_back(ConditionExpression::fromWire( | |||
| @@ -1901,7 +1998,8 @@ LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( | |||
| const std::string &rung_id, | |||
| const std::string &gap_expression_id, | |||
| int column_offset, | |||
| const LogicNodeConfig &config) | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const ConditionExpression *gap = findExpression( | |||
| @@ -1926,7 +2024,8 @@ LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( | |||
| ConditionExpression replacement; | |||
| if (leading_columns == 0 && trailing_columns == 0) | |||
| { | |||
| replacement = ConditionExpression::fromNode(makeNode(node_id, config)); | |||
| replacement = ConditionExpression::fromNode( | |||
| makeNode(node_id, config, configured)); | |||
| } | |||
| else | |||
| { | |||
| @@ -1938,7 +2037,7 @@ LogicEditorResult LogicEditorService::replaceGapColumnWithCondition( | |||
| gap_expression_id, leading_columns)); | |||
| } | |||
| replacement.children.push_back( | |||
| ConditionExpression::fromNode(makeNode(node_id, config))); | |||
| ConditionExpression::fromNode(makeNode(node_id, config, configured))); | |||
| if (trailing_columns > 0) | |||
| { | |||
| replacement.children.push_back(ConditionExpression::fromGap( | |||
| @@ -2032,7 +2131,8 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_node_ids, | |||
| const LogicNodeConfig &config) | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) | |||
| @@ -2068,7 +2168,8 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||
| "并联选择中包含未知节点"); | |||
| } | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | |||
| ConditionExpression leaf = ConditionExpression::fromNode( | |||
| makeNode(node_id, config, configured)); | |||
| const std::string parallel_id = makeUniqueExpressionId(*logic); | |||
| std::string series_id = parallel_id + "-range"; | |||
| while (findExpression(logic_id, rung_id, series_id) != nullptr) | |||
| @@ -2105,6 +2206,73 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addParallelToWholeCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config, | |||
| bool configured) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| const LadderRung *existing_rung = findRung(logic_id, rung_id); | |||
| if (logic == nullptr || existing_rung == nullptr | |||
| || !existing_rung->condition.has_value()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::RungNotFound, | |||
| "OR/ORI 前必须先有一段条件"); | |||
| } | |||
| if (!isConditionConfig(config)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "并联支路只能使用条件节点"); | |||
| } | |||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| ConditionExpression branch = ConditionExpression::fromNode( | |||
| makeNode(node_id, config, configured)); | |||
| const std::string parallel_id = makeUniqueExpressionId(*logic); | |||
| std::string branch_series_id = parallel_id + "-branch"; | |||
| while (findExpression(logic_id, rung_id, branch_series_id) != nullptr) | |||
| { | |||
| branch_series_id += "-branch"; | |||
| } | |||
| const int original_columns = expressionColumns(*existing_rung->condition); | |||
| const std::string padding_wire_id = original_columns > 1 | |||
| ? makeUniqueWireId(*logic) : std::string{}; | |||
| HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| auto editable_logic = std::find_if( | |||
| project.controlLogics.begin(), project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | |||
| LadderRung *rung = findEditableRung(project, logic_id, rung_id); | |||
| ConditionExpression original = std::move(*rung->condition); | |||
| if (original_columns > 1) | |||
| { | |||
| ConditionExpression padded; | |||
| padded.id = branch_series_id; | |||
| padded.kind = ConditionExpressionKind::Series; | |||
| padded.children.push_back(std::move(branch)); | |||
| padded.children.push_back(ConditionExpression::fromWire( | |||
| padding_wire_id, original_columns - 1)); | |||
| branch = std::move(padded); | |||
| } | |||
| rung->condition = makeContainer( | |||
| parallel_id, | |||
| ConditionExpressionKind::Parallel, | |||
| std::move(original), | |||
| std::move(branch)); | |||
| std::string validation_error; | |||
| if (!repairExplicitLayout(*editable_logic, *rung, &validation_error) | |||
| || !rung->validate(&validation_error)) | |||
| { | |||
| rollbackEdit(std::move(before), modified_before); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::addParallelWireBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| @@ -2544,13 +2712,13 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||
| { | |||
| return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); | |||
| } | |||
| if (node->config.index() != config.index()) | |||
| LogicNode candidate{node_id, config, true}; | |||
| if (node->isCondition() != candidate.isCondition()) | |||
| { | |||
| return failure( | |||
| LogicEditorError::UnsupportedNodeChange, | |||
| "节点创建后不能修改节点类别"); | |||
| "不能把条件节点改成输出节点,或把输出节点改成条件节点"); | |||
| } | |||
| LogicNode candidate{node_id, config, true}; | |||
| std::string error; | |||
| if (!candidate.validate(&error)) | |||
| { | |||
| @@ -108,6 +108,10 @@ public: | |||
| /** @brief 删除指定网络 */ | |||
| LogicEditorResult removeRung( | |||
| const std::string &logic_id, const std::string &rung_id); | |||
| /** @brief 批量删除指定网络,失败时整体回滚 */ | |||
| LogicEditorResult removeRungs( | |||
| const std::string &logic_id, | |||
| const std::vector<std::string> &rung_ids); | |||
| /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ | |||
| LogicEditorResult updateRungComment( | |||
| const std::string &logic_id, | |||
| @@ -118,7 +122,8 @@ public: | |||
| LogicEditorResult appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 按绝对条件列插入条件节点 | |||
| * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 | |||
| @@ -127,7 +132,8 @@ public: | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 在直属并联分支的指定视觉列插入条件节点 | |||
| * | |||
| @@ -138,7 +144,8 @@ public: | |||
| const std::string &rung_id, | |||
| const std::string &branch_expression_id, | |||
| int column, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 在网络条件末尾追加指定列宽的横线 */ | |||
| LogicEditorResult appendWire( | |||
| const std::string &logic_id, | |||
| @@ -168,14 +175,16 @@ public: | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| int column_offset, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ | |||
| LogicEditorResult replaceGapColumnWithCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &gap_expression_id, | |||
| int column_offset, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 用一格横线修复断路表达式中的指定网格 */ | |||
| LogicEditorResult replaceGapColumnWithWire( | |||
| const std::string &logic_id, | |||
| @@ -190,7 +199,14 @@ public: | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_node_ids, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 将当前网络的完整条件表达式与一个新条件并联 */ | |||
| LogicEditorResult addParallelToWholeCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 将选中的横线表达式建立为并联旁路 */ | |||
| LogicEditorResult addParallelWireBranch( | |||
| const std::string &logic_id, | |||
| @@ -9,6 +9,7 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include "services/logic_command_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/software_logic_executor.h" | |||
| @@ -19,6 +20,11 @@ | |||
| #include <vector> | |||
| class QGraphicsScene; | |||
| class QCompleter; | |||
| class QEvent; | |||
| class QLineEdit; | |||
| class QMouseEvent; | |||
| class QRubberBand; | |||
| class QResizeEvent; | |||
| /** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ | |||
| @@ -35,6 +41,7 @@ public: | |||
| class VerticalConnectorItem; | |||
| class RungItem; | |||
| class EmptySlotItem; | |||
| class OutputSlotItem; | |||
| /** 创建梯形图画布并绑定编辑服务 */ | |||
| explicit LogicEditorWidget( | |||
| @@ -95,8 +102,20 @@ signals: | |||
| void editorError(const QString &message); | |||
| protected: | |||
| /** 在任意网络区域开始鼠标框选 */ | |||
| void mousePressEvent(QMouseEvent *event) override; | |||
| /** 更新鼠标框选区域 */ | |||
| void mouseMoveEvent(QMouseEvent *event) override; | |||
| /** 完成鼠标框选并提交场景选择 */ | |||
| void mouseReleaseEvent(QMouseEvent *event) override; | |||
| /** 在条件网格或输出槽双击时打开命令输入框 */ | |||
| void mouseDoubleClickEvent(QMouseEvent *event) override; | |||
| /** 在窗口大小变化后重新布局梯形图场景 */ | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| /** 滚动画布时同步命令输入框位置 */ | |||
| void scrollContentsBy(int dx, int dy) override; | |||
| /** 处理命令输入框的回车、取消和按键事件 */ | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| private: | |||
| /** 处理图形场景选择变化 */ | |||
| @@ -123,9 +142,31 @@ private: | |||
| std::vector<std::pair<std::string, int>> selectedGapCells() const; | |||
| /** 判断当前是否选中了网络内的图元 */ | |||
| bool hasSelectedRungItem() const; | |||
| /** 收集当前明确选中的网络图元 */ | |||
| std::vector<std::string> selectedRungItemIds() const; | |||
| /** 打开指定目标位置的命令输入框 */ | |||
| void beginCommandInput( | |||
| const LogicCommandTarget &target, | |||
| const QPointF &scene_center); | |||
| /** 提交当前命令输入;失败时保留输入框并标红 */ | |||
| void commitCommandInput(); | |||
| /** 关闭命令输入框并清理临时状态 */ | |||
| void cancelCommandInput(); | |||
| /** 根据网络、列和支路标识查找下一输入位置 */ | |||
| bool beginNextCommandInput(); | |||
| /** 按目标定位输入框的屏幕矩形 */ | |||
| void positionCommandInput(const QPointF &scene_center); | |||
| /** 返回当前场景目标的中心位置 */ | |||
| bool findCommandTargetCenter( | |||
| const LogicCommandTarget &target, | |||
| QPointF *scene_center) const; | |||
| /** 将成功命令选中并刷新属性面板 */ | |||
| void selectCommandResult(const LogicCommandResult &result); | |||
| /** 梯形图编辑服务,不由控件拥有 */ | |||
| LogicEditorService &editor_service_; | |||
| /** 命令语解析和结构化编辑适配服务 */ | |||
| LogicCommandService command_service_; | |||
| /** 承载梯形图图元的场景 */ | |||
| QGraphicsScene *scene_ = nullptr; | |||
| /** 当前显示的控制逻辑标识 */ | |||
| @@ -140,4 +181,26 @@ private: | |||
| bool runtime_trace_enabled_ = false; | |||
| /** 是否允许编辑梯形图 */ | |||
| bool editing_enabled_ = true; | |||
| /** 鼠标框选覆盖层 */ | |||
| QRubberBand *selection_band_ = nullptr; | |||
| /** 框选起点(视口坐标) */ | |||
| QPoint selection_origin_; | |||
| /** 框选时是否已超过拖拽阈值 */ | |||
| bool selection_dragging_ = false; | |||
| /** 框选起始时的键盘修饰键 */ | |||
| Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; | |||
| /** 命令输入框,仅在编辑态临时创建 */ | |||
| QLineEdit *command_editor_ = nullptr; | |||
| /** 命令输入补全器 */ | |||
| QCompleter *command_completer_ = nullptr; | |||
| /** 当前命令输入目标 */ | |||
| LogicCommandTarget command_target_; | |||
| /** 是否沿当前命令序列继续输入 */ | |||
| bool command_continuing_ = false; | |||
| /** OR/ORI 只增加支路,不推进主表达式列 */ | |||
| bool command_keep_column_ = false; | |||
| /** 连续输入对应的当前网络 */ | |||
| std::string command_current_rung_id_; | |||
| /** 编辑已有网络 OR 时保留的选择范围 */ | |||
| std::vector<std::string> command_parallel_node_ids_; | |||
| }; | |||
| @@ -1,6 +1,7 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/project_limits.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/logic_command_service.h" | |||
| #include "services/editor_history.h" | |||
| #include "services/project_service.h" | |||
| #include "support/test_support.h" | |||
| @@ -130,6 +131,244 @@ const ConditionExpression *firstExpressionOfKind( | |||
| return nullptr; | |||
| } | |||
| void testLogicCommandParsing() | |||
| { | |||
| const LogicCommandParseResult load = LogicCommandService::parse("ldi m4000"); | |||
| require(load.succeeded | |||
| && load.command.opcode == LogicCommandOpcode::LoadInverse | |||
| && std::get<ContactNodeConfig>(load.command.config).address.index() == 4000 | |||
| && std::get<ContactNodeConfig>(load.command.config).mode | |||
| == ContactMode::NormallyClosed, | |||
| "LDI must parse case-insensitively into a configured contact"); | |||
| const LogicCommandParseResult add = LogicCommandService::parse( | |||
| "ADD D0 D1 D4000"); | |||
| require(add.succeeded | |||
| && add.command.opcode == LogicCommandOpcode::Add | |||
| && std::get<ArithmeticNodeConfig>(add.command.config) | |||
| .destination.index() == 4000, | |||
| "ADD must parse three D operands through the shared address rules"); | |||
| const LogicCommandParseResult compare = LogicCommandService::parse( | |||
| "LD<= D12 -3"); | |||
| require(compare.succeeded | |||
| && compare.command.opcode | |||
| == LogicCommandOpcode::CompareLessThanOrEqual | |||
| && std::get<CompareNodeConfig>(compare.command.config).address.index() == 12 | |||
| && std::get<CompareNodeConfig>(compare.command.config).value == -3, | |||
| "LD comparison commands must parse a D address and signed constant"); | |||
| const LogicCommandParseResult move_constant = LogicCommandService::parse( | |||
| "MOV -7 D20"); | |||
| require(move_constant.succeeded | |||
| && std::get<MoveNodeConfig>(move_constant.command.config).source.kind | |||
| == WordOperandKind::Constant | |||
| && std::get<MoveNodeConfig>(move_constant.command.config).source.constant == -7, | |||
| "MOV must preserve constant sources when an existing output is reopened"); | |||
| const LogicCommandParseResult add_constant = LogicCommandService::parse( | |||
| "ADD D0 1 D2"); | |||
| require(add_constant.succeeded | |||
| && std::get<ArithmeticNodeConfig>(add_constant.command.config).right.kind | |||
| == WordOperandKind::Constant, | |||
| "ADD must accept constant operands used by the instruction dialog"); | |||
| require(!LogicCommandService::parse("LD D0").succeeded, | |||
| "contact commands must reject D addresses"); | |||
| require(!LogicCommandService::parse("ADD D0 D1 D4001").succeeded, | |||
| "data commands must reject out-of-range D addresses"); | |||
| require(!LogicCommandService::parse("TON T0").succeeded, | |||
| "unsupported commands must be rejected explicitly"); | |||
| require(!LogicCommandService::parse("OUT M0 M1").succeeded, | |||
| "commands with the wrong operand count must be rejected"); | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService editor(project_service); | |||
| LogicCommandService commands(editor); | |||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||
| const std::string rung_id = makeEmptyRung(editor, logic_id); | |||
| LogicCommandRequest and_without_load; | |||
| and_without_load.logicId = logic_id; | |||
| and_without_load.text = "AND M0"; | |||
| and_without_load.target = { | |||
| LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; | |||
| require(!commands.execute(and_without_load).succeeded, | |||
| "AND without a preceding LD must be rejected"); | |||
| } | |||
| void testContinuousLogicCommands() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService editor(project_service); | |||
| LogicCommandService commands(editor); | |||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||
| const std::string rung_id = makeEmptyRung(editor, logic_id); | |||
| LogicCommandRequest request; | |||
| request.logicId = logic_id; | |||
| request.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 0}; | |||
| request.text = "LD M1"; | |||
| const LogicCommandResult load = commands.execute(request); | |||
| require(load.succeeded && load.rungId == rung_id, | |||
| "LD on an empty cell must create the first condition in that network"); | |||
| request.continuing = true; | |||
| request.currentRungId = load.rungId; | |||
| request.text = "AND M2"; | |||
| const LogicCommandResult and_result = commands.execute(request); | |||
| require(and_result.succeeded, | |||
| "AND must append to the current command-input network"); | |||
| request.text = "OR M3"; | |||
| const LogicCommandResult or_result = commands.execute(request); | |||
| require(or_result.succeeded, | |||
| "OR must parallel the complete accumulated condition: " | |||
| + or_result.message); | |||
| const LadderRung *rung = editor.findRung(logic_id, rung_id); | |||
| const ConditionExpression *parallel = firstExpressionOfKind( | |||
| *rung->condition, ConditionExpressionKind::Parallel); | |||
| require(parallel != nullptr && parallel->children.size() == 2U | |||
| && parallel->children.front().kind == ConditionExpressionKind::Series | |||
| && conditionNodes(parallel->children.front()) == 2 | |||
| && conditionNodes(parallel->children.back()) == 1, | |||
| "LD M1, AND M2, OR M3 must form (M1 AND M2) OR M3"); | |||
| request.text = "OUT M4"; | |||
| const LogicCommandResult output = commands.execute(request); | |||
| rung = editor.findRung(logic_id, rung_id); | |||
| require(output.succeeded && rung != nullptr && rung->validateForRunning(), | |||
| "command-created contacts and output must be configured and runnable"); | |||
| request.text = "LDI M5"; | |||
| const LogicCommandResult next_load = commands.execute(request); | |||
| require(next_load.succeeded && next_load.rungId != rung_id | |||
| && editor.findLogic(logic_id)->rungs.size() == 2U, | |||
| "a consecutive LD after an output must create the next network"); | |||
| } | |||
| void testExistingNodeCommandReplacement() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService editor(project_service); | |||
| LogicCommandService commands(editor); | |||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||
| const std::string rung_id = makeEmptyRung(editor, logic_id); | |||
| const LogicEditorResult contact_result = editor.appendCondition( | |||
| logic_id, rung_id, contact(10), true); | |||
| require(contact_result.succeeded, | |||
| "existing-node command fixture must create a contact"); | |||
| LogicCommandRequest replace_contact; | |||
| replace_contact.logicId = logic_id; | |||
| replace_contact.text = "LDI M11"; | |||
| replace_contact.target = { | |||
| LogicCommandTargetKind::ExistingNode, | |||
| rung_id, | |||
| contact_result.id, | |||
| 0}; | |||
| const LogicCommandResult replaced_contact = commands.execute(replace_contact); | |||
| const LogicNode *contact_node = editor.findNode(logic_id, contact_result.id); | |||
| require(replaced_contact.succeeded && contact_node != nullptr | |||
| && std::holds_alternative<ContactNodeConfig>(contact_node->config) | |||
| && std::get<ContactNodeConfig>(contact_node->config).address.index() == 11 | |||
| && std::get<ContactNodeConfig>(contact_node->config).mode | |||
| == ContactMode::NormallyClosed, | |||
| "an existing contact must accept a load command replacement"); | |||
| const LogicEditorResult output_result = editor.setOutput( | |||
| logic_id, | |||
| rung_id, | |||
| CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, | |||
| true); | |||
| require(output_result.succeeded, | |||
| "existing-node command fixture must create an output"); | |||
| LogicCommandRequest replace_output; | |||
| replace_output.logicId = logic_id; | |||
| replace_output.text = "SET M21"; | |||
| replace_output.target = { | |||
| LogicCommandTargetKind::ExistingNode, | |||
| rung_id, | |||
| output_result.id, | |||
| 0}; | |||
| const LogicCommandResult replaced_output = commands.execute(replace_output); | |||
| const LogicNode *output_node = editor.findNode(logic_id, output_result.id); | |||
| require(replaced_output.succeeded && output_node != nullptr | |||
| && std::holds_alternative<CoilNodeConfig>(output_node->config) | |||
| && std::get<CoilNodeConfig>(output_node->config).address.index() == 21 | |||
| && std::get<CoilNodeConfig>(output_node->config).mode == CoilMode::Set, | |||
| "an existing output must accept an output command replacement"); | |||
| LogicCommandRequest wrong_type = replace_contact; | |||
| wrong_type.text = "OUT M30"; | |||
| require(!commands.execute(wrong_type).succeeded, | |||
| "an existing condition must reject an output command replacement"); | |||
| } | |||
| void testPositionedLogicCommandsAndAtomicFailures() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService editor(project_service); | |||
| LogicCommandService commands(editor); | |||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||
| const std::string rung_id = makeEmptyRung(editor, logic_id); | |||
| const LogicEditorResult first = editor.appendCondition( | |||
| logic_id, rung_id, contact(10), true); | |||
| const LogicEditorResult second = editor.appendCondition( | |||
| logic_id, rung_id, contact(11), true); | |||
| LogicCommandRequest selected_or; | |||
| selected_or.logicId = logic_id; | |||
| selected_or.text = "ORI M12"; | |||
| selected_or.target = {LogicCommandTargetKind::EmptyColumn, rung_id, {}, 2}; | |||
| selected_or.parallelNodeIds = {first.id, second.id}; | |||
| require(commands.execute(selected_or).succeeded, | |||
| "ORI on an existing network must use the selected continuous range"); | |||
| const std::string wire_rung = makeEmptyRung(editor, logic_id); | |||
| const LogicEditorResult wire = editor.appendWire(logic_id, wire_rung, 3); | |||
| LogicCommandRequest wire_command; | |||
| wire_command.logicId = logic_id; | |||
| wire_command.text = "LDP M20"; | |||
| wire_command.target = { | |||
| LogicCommandTargetKind::WireColumn, wire_rung, wire.id, 1}; | |||
| const LogicCommandResult inserted = commands.execute(wire_command); | |||
| require(inserted.succeeded | |||
| && editor.findNode(logic_id, inserted.id)->isConfigured(), | |||
| "a command on a wire cell must replace exactly that cell"); | |||
| const std::string gap_rung = makeEmptyRung(editor, logic_id); | |||
| const LogicEditorResult gap_wire = editor.appendWire(logic_id, gap_rung, 2); | |||
| require(editor.disconnectWireCells(logic_id, gap_rung, {{gap_wire.id, 0}}).succeeded, | |||
| "gap command fixture must disconnect one wire cell"); | |||
| const ConditionExpression *gap = firstExpressionOfKind( | |||
| *editor.findRung(logic_id, gap_rung)->condition, | |||
| ConditionExpressionKind::Gap); | |||
| LogicCommandRequest gap_command; | |||
| gap_command.logicId = logic_id; | |||
| gap_command.text = "LD M21"; | |||
| gap_command.target = { | |||
| LogicCommandTargetKind::GapColumn, gap_rung, gap->id, 0}; | |||
| require(commands.execute(gap_command).succeeded | |||
| && gapColumns(*editor.findRung(logic_id, gap_rung)->condition) == 0, | |||
| "a command on a gap cell must repair that exact gap"); | |||
| editor.clearHistory(); | |||
| const std::size_t rung_count = editor.findLogic(logic_id)->rungs.size(); | |||
| LogicCommandRequest invalid = gap_command; | |||
| invalid.text = "ADD D0 D1 D4001"; | |||
| require(!commands.execute(invalid).succeeded | |||
| && editor.findLogic(logic_id)->rungs.size() == rung_count | |||
| && !editor.canUndo(), | |||
| "an invalid command must not change the project or undo history"); | |||
| LogicCommandRequest wrong_position = gap_command; | |||
| wrong_position.text = "OUT M30"; | |||
| require(!commands.execute(wrong_position).succeeded | |||
| && !editor.canUndo(), | |||
| "an output command in the condition area must be rejected atomically"); | |||
| } | |||
| void testEmptyLogicCreatesNetworksOnFirstEdit() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -178,6 +417,33 @@ void testEmptyLogicCreatesNetworksOnFirstEdit() | |||
| "the first output must create a full-width explicit wire network"); | |||
| } | |||
| void testBatchRungDeleteIsAtomic() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||
| const std::string first = service.addRung(logic_id).id; | |||
| const std::string second = service.addRung(logic_id).id; | |||
| const std::string third = service.addRung(logic_id).id; | |||
| require(service.findLogic(logic_id)->rungs.size() == 3U, | |||
| "the fixture must create three networks"); | |||
| require(service.removeRungs(logic_id, {first, second}).succeeded | |||
| && service.findLogic(logic_id)->rungs.size() == 1U | |||
| && service.findRung(logic_id, third) != nullptr, | |||
| "a selected group of networks must be deleted together"); | |||
| require(service.undo().succeeded | |||
| && service.findLogic(logic_id)->rungs.size() == 3U, | |||
| "undo must restore a batch network deletion as one edit"); | |||
| service.clearHistory(); | |||
| require(!service.removeRungs(logic_id, {first, first}).succeeded | |||
| && service.findLogic(logic_id)->rungs.size() == 3U | |||
| && !service.canUndo(), | |||
| "duplicate network selection must fail without mutation or history"); | |||
| } | |||
| void testAppendingWireMovesToNextNetworkAfterTenColumns() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -203,6 +469,38 @@ void testAppendingWireMovesToNextNetworkAfterTenColumns() | |||
| "automatic network rollover must be undone as one edit"); | |||
| } | |||
| void testAppendingWireReusesDeletedGapCells() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||
| const std::string rung_id = makeEmptyRung(service, logic_id); | |||
| const LogicEditorResult full_wire = service.appendWire( | |||
| logic_id, rung_id, ProjectLimits::kMaximumConditionColumns); | |||
| require(full_wire.succeeded, "gap reuse setup must create a full-width wire"); | |||
| require(service.disconnectWires(logic_id, rung_id, {full_wire.id}).succeeded, | |||
| "deleting the full-width wire must create reusable gaps"); | |||
| LogicEditorResult current = service.appendWire(logic_id, rung_id); | |||
| require(current.succeeded, | |||
| "the first appended wire must reuse the first deleted gap cell"); | |||
| for (int index = 1; index < ProjectLimits::kMaximumConditionColumns; ++index) | |||
| { | |||
| current = service.insertWireAfter(logic_id, rung_id, current.id); | |||
| require(current.succeeded, | |||
| "inserting after a selected wire must reuse the next deleted gap cell"); | |||
| require(service.findLogic(logic_id)->rungs.size() == 1U, | |||
| "reusing a gap must not create a new network"); | |||
| } | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && rung->condition.has_value() | |||
| && conditionColumns(*rung->condition) | |||
| == ProjectLimits::kMaximumConditionColumns | |||
| && gapColumns(*rung->condition) == 0, | |||
| "all deleted gap cells must be restored as horizontal wires"); | |||
| } | |||
| void testStructuredEditingAndNormalization() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -1464,8 +1762,14 @@ int main() | |||
| { | |||
| try | |||
| { | |||
| testLogicCommandParsing(); | |||
| testContinuousLogicCommands(); | |||
| testExistingNodeCommandReplacement(); | |||
| testPositionedLogicCommandsAndAtomicFailures(); | |||
| testEmptyLogicCreatesNetworksOnFirstEdit(); | |||
| testBatchRungDeleteIsAtomic(); | |||
| testAppendingWireMovesToNextNetworkAfterTenColumns(); | |||
| testAppendingWireReusesDeletedGapCells(); | |||
| testStructuredEditingAndNormalization(); | |||
| testRangeParallelInsertion(); | |||
| testParallelBranchGridInsertion(); | |||
| @@ -91,11 +91,13 @@ SERVICE_HMI_HEADERS = \ | |||
| ../src/services/hmi_runtime_service.h | |||
| SERVICE_LOGIC_SOURCES = \ | |||
| ../src/services/logic_editor_service.cpp | |||
| ../src/services/logic_editor_service.cpp \ | |||
| ../src/services/logic_command_service.cpp | |||
| SERVICE_LOGIC_HEADERS = \ | |||
| ../src/services/editor_history.h \ | |||
| ../src/services/logic_editor_service.h | |||
| ../src/services/logic_editor_service.h \ | |||
| ../src/services/logic_command_service.h | |||
| SERVICE_OFFLINE_SOURCES = \ | |||
| ../src/services/software_logic_executor.cpp \ | |||