瀏覽代碼

feat: 完善梯形图控制逻辑编辑

main
suyu 1 月之前
父節點
當前提交
0fb0efc9bc
共有 16 個檔案被更改,包括 1210 行新增1085 行删除
  1. +115
    -177
      app/src/domain/control_logic_model.cpp
  2. +32
    -61
      app/src/domain/control_logic_model.h
  3. +125
    -98
      app/src/infrastructure/json_project_storage.cpp
  4. +340
    -203
      app/src/services/logic_editor_service.cpp
  5. +28
    -20
      app/src/services/logic_editor_service.h
  6. +330
    -397
      app/src/ui/logic_editor_widget.cpp
  7. +11
    -18
      app/src/ui/logic_editor_widget.h
  8. +60
    -24
      app/src/ui/main_window.cpp
  9. +9
    -3
      app/src/ui/main_window.h
  10. +46
    -28
      app/tests/domain_tests.cpp
  11. +54
    -25
      app/tests/logic_editor_service_tests.cpp
  12. +11
    -2
      app/tests/main_window_tests.cpp
  13. +31
    -12
      app/tests/project_management_tests.cpp
  14. +7
    -7
      docs/ai/handoff.md
  15. +9
    -9
      docs/architecture.md
  16. +2
    -1
      docs/开发顺序.md

+ 115
- 177
app/src/domain/control_logic_model.cpp 查看文件

@@ -1,12 +1,9 @@
#include "control_logic_model.h"

#include <algorithm>
#include <unordered_map>
#include <unordered_set>

namespace {

// 在提供错误字符串时写入校验失败原因
void setError(std::string *error, const std::string &message)
{
if (error != nullptr)
@@ -15,30 +12,6 @@ void setError(std::string *error, const std::string &message)
}
}

// 判断触点工作方式是否受支持
bool isSupportedMode(ContactMode mode)
{
return mode == ContactMode::NormallyOpen || mode == ContactMode::NormallyClosed;
}

// 判断线圈工作方式是否受支持
bool isSupportedMode(CoilMode mode)
{
return mode == CoilMode::Normal || mode == CoilMode::Set || mode == CoilMode::Reset;
}

// 判断数值比较运算符是否受支持
bool isSupportedComparison(ComparisonOperator comparison)
{
return comparison == ComparisonOperator::Equal
|| comparison == ComparisonOperator::NotEqual
|| comparison == ComparisonOperator::LessThan
|| comparison == ComparisonOperator::LessThanOrEqual
|| comparison == ComparisonOperator::GreaterThan
|| comparison == ComparisonOperator::GreaterThanOrEqual;
}

// 校验触点节点配置
bool validateConfig(const ContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -46,7 +19,8 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error)
setError(error, "contact node requires a valid M address");
return false;
}
if (!isSupportedMode(config.mode))
if (config.mode != ContactMode::NormallyOpen
&& config.mode != ContactMode::NormallyClosed)
{
setError(error, "contact node has an unsupported mode");
return false;
@@ -54,7 +28,6 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error)
return true;
}

// 校验线圈节点配置
bool validateConfig(const CoilNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -62,7 +35,9 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error)
setError(error, "coil node requires a valid M address");
return false;
}
if (!isSupportedMode(config.mode))
if (config.mode != CoilMode::Normal
&& config.mode != CoilMode::Set
&& config.mode != CoilMode::Reset)
{
setError(error, "coil node has an unsupported mode");
return false;
@@ -70,7 +45,6 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error)
return true;
}

// 校验数值比较节点配置
bool validateConfig(const CompareNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::D)
@@ -78,7 +52,12 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)
setError(error, "comparison node requires a valid D address");
return false;
}
if (!isSupportedComparison(config.comparison))
if (config.comparison != ComparisonOperator::Equal
&& config.comparison != ComparisonOperator::NotEqual
&& config.comparison != ComparisonOperator::LessThan
&& config.comparison != ComparisonOperator::LessThanOrEqual
&& config.comparison != ComparisonOperator::GreaterThan
&& config.comparison != ComparisonOperator::GreaterThanOrEqual)
{
setError(error, "comparison node has an unsupported operator");
return false;
@@ -86,14 +65,27 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)
return true;
}

} // namespace

bool LogicPoint::isValid() const
template<typename TItem>
bool hasDuplicateId(const std::vector<TItem> &items)
{
return x >= kMinimumCoordinate && x <= kMaximumCoordinate
&& y >= kMinimumCoordinate && y <= kMaximumCoordinate;
for (auto current = items.cbegin(); current != items.cend(); ++current)
{
if (std::find_if(
current + 1,
items.cend(),
[&current](const TItem &candidate)
{
return candidate.id == current->id;
}) != items.cend())
{
return true;
}
}
return false;
}

} // namespace

bool LogicNode::validate(std::string *error) const
{
if (id.empty())
@@ -101,220 +93,166 @@ bool LogicNode::validate(std::string *error) const
setError(error, "logic node id must not be empty");
return false;
}
if (!position.isValid())
{
setError(error, "logic node position is outside the supported range");
return false;
}

// 根据 variant 中实际保存的配置类型调用对应校验函数
return std::visit(
[error](const auto &nodeConfig)
[error](const auto &config)
{
return validateConfig(nodeConfig, error);
return validateConfig(config, error);
},
config);
}

bool LogicConnection::validate(std::string *error) const
bool LogicNode::isCondition() const
{
// 连接两端必须存在且不能指向同一个节点
if (fromNodeId.empty() || toNodeId.empty() || fromNodeId == toNodeId)
{
setError(error, "logic connection must connect two different nodes");
return false;
}
return true;
return !std::holds_alternative<CoilNodeConfig>(config);
}

bool ControlLogic::validate(std::string *error) const
bool LogicNode::isOutput() const
{
if (!validateStructure(error))
return std::holds_alternative<CoilNodeConfig>(config);
}

bool LadderStage::validate(std::string *error) const
{
if (id.empty() || branches.empty())
{
setError(error, "ladder stage id and branches must not be empty");
return false;
}
if (id.empty() || name.empty())
if (hasDuplicateId(branches))
{
setError(error, "control logic id and name must not be empty");
setError(error, "parallel branch node ids must be unique");
return false;
}

if (nodes.empty())
{
return true;
}

std::unordered_map<std::string, std::size_t> incoming_count;
for (const LogicNode &node : nodes)
{
incoming_count.emplace(node.id, 0U);
}
for (const LogicConnection &connection : connections)
{
++incoming_count.at(connection.toNodeId);
}

// 线圈必须由至少一个条件路径驱动,避免孤立线圈在执行时意外写入 M 位
for (const LogicNode &node : nodes)
for (const LogicNode &node : branches)
{
if (std::holds_alternative<CoilNodeConfig>(node.config)
&& incoming_count.at(node.id) == 0U)
if (!node.validate(error))
{
setError(error, "coil node must have at least one input connection");
return false;
}
}

// 从所有线圈沿反向连接遍历,一次找出能够产生动作的全部条件节点
std::unordered_map<std::string, std::vector<std::string>> incoming;
std::vector<std::string> pending;
for (const LogicNode &node : nodes)
{
incoming.emplace(node.id, std::vector<std::string>{});
if (std::holds_alternative<CoilNodeConfig>(node.config))
if (!node.isCondition())
{
pending.push_back(node.id);
setError(error, "ladder stage may contain condition nodes only");
return false;
}
}
for (const LogicConnection &connection : connections)
return true;
}

bool LadderRung::validate(std::string *error) const
{
if (!validateStructure(error))
{
incoming.at(connection.toNodeId).push_back(connection.fromNodeId);
return false;
}
std::unordered_set<std::string> reaches_coil;
while (!pending.empty())
if (stages.empty() && !output.has_value())
{
const std::string current = pending.back();
pending.pop_back();
if (!reaches_coil.insert(current).second)
{
continue;
}
for (const std::string &previous : incoming.at(current))
{
pending.push_back(previous);
}
return true;
}
if (reaches_coil.size() != nodes.size())
if (stages.empty() || !output.has_value())
{
setError(error, "condition node must reach a coil node");
setError(error, "non-empty ladder rung requires conditions and an output coil");
return false;
}
return true;
}

bool ControlLogic::validateStructure(std::string *error) const
bool LadderRung::validateStructure(std::string *error) const
{
if (id.empty() || name.empty())
{
setError(error, "control logic id and name must not be empty");
setError(error, "ladder rung id and name must not be empty");
return false;
}
if (hasDuplicateId(stages))
{
setError(error, "ladder stage ids must be unique within a rung");
return false;
}

// 校验所有节点并收集节点标识用于后续检查连接引用
std::vector<std::string> node_ids;
node_ids.reserve(nodes.size());
for (const LogicNode &node : nodes)
for (const LadderStage &stage : stages)
{
if (!node.validate(error))
if (!stage.validate(error))
{
return false;
}
if (std::find(node_ids.cbegin(), node_ids.cend(), node.id) != node_ids.cend())
for (const LogicNode &node : stage.branches)
{
setError(error, "logic node ids must be unique within a logic");
return false;
node_ids.push_back(node.id);
}
node_ids.push_back(node.id);
}

// 每条连接只能引用当前控制逻辑中已经定义的节点
for (const LogicConnection &connection : connections)
if (output.has_value())
{
if (!connection.validate(error))
if (!output->validate(error))
{
return false;
}
if (std::find(node_ids.cbegin(), node_ids.cend(), connection.fromNodeId)
== node_ids.cend()
|| std::find(node_ids.cbegin(), node_ids.cend(), connection.toNodeId)
== node_ids.cend())
if (!output->isOutput())
{
setError(error, "logic connection references an unknown node");
setError(error, "ladder rung output must be a coil node");
return false;
}
node_ids.push_back(output->id);
}

// 同一对节点只能存在一条有向连接,避免执行语义和删除行为产生歧义
for (auto current = connections.cbegin(); current != connections.cend(); ++current)
std::sort(node_ids.begin(), node_ids.end());
if (std::adjacent_find(node_ids.cbegin(), node_ids.cend()) != node_ids.cend())
{
const auto duplicate = std::find_if(
current + 1,
connections.cend(),
[current](const LogicConnection &candidate)
{
return candidate.fromNodeId == current->fromNodeId
&& candidate.toNodeId == current->toNodeId;
});
if (duplicate != connections.cend())
{
setError(error, "logic connections must be unique");
return false;
}
setError(error, "logic node ids must be unique within a rung");
return false;
}
return true;
}

std::unordered_map<std::string, std::vector<std::string>> outgoing;
std::unordered_map<std::string, std::size_t> incoming_count;
for (const LogicNode &node : nodes)
bool ControlLogic::validate(std::string *error) const
{
if (!validateStructure(error))
{
outgoing.emplace(node.id, std::vector<std::string>{});
incoming_count.emplace(node.id, 0U);
return false;
}
for (const LogicConnection &connection : connections)
for (const LadderRung &rung : rungs)
{
const auto from = std::find_if(
nodes.cbegin(),
nodes.cend(),
[&connection](const LogicNode &node)
{
return node.id == connection.fromNodeId;
});
if (std::holds_alternative<CoilNodeConfig>(from->config))
if (!rung.validate(error))
{
setError(error, "coil node cannot have outgoing connections");
return false;
}
outgoing.at(connection.fromNodeId).push_back(connection.toNodeId);
++incoming_count.at(connection.toNodeId);
}
return true;
}

// 使用迭代拓扑排序校验环路,不改变节点的持久化顺序
std::vector<std::string> pending;
for (const auto &entry : incoming_count)
bool ControlLogic::validateStructure(std::string *error) const
{
if (id.empty() || name.empty())
{
if (entry.second == 0U)
{
pending.push_back(entry.first);
}
setError(error, "control logic id and name must not be empty");
return false;
}
if (hasDuplicateId(rungs))
{
setError(error, "ladder rung ids must be unique within a logic");
return false;
}
std::size_t visited_count = 0U;
while (!pending.empty())
std::vector<std::string> node_ids;
for (const LadderRung &rung : rungs)
{
const std::string current = pending.back();
pending.pop_back();
++visited_count;
for (const std::string &next : outgoing.at(current))
if (!rung.validateStructure(error))
{
return false;
}
for (const LadderStage &stage : rung.stages)
{
std::size_t &count = incoming_count.at(next);
--count;
if (count == 0U)
for (const LogicNode &node : stage.branches)
{
pending.push_back(next);
node_ids.push_back(node.id);
}
}
if (rung.output.has_value())
{
node_ids.push_back(rung.output->id);
}
}
if (visited_count != nodes.size())
std::sort(node_ids.begin(), node_ids.end());
if (std::adjacent_find(node_ids.cbegin(), node_ids.cend()) != node_ids.cend())
{
setError(error, "logic connections must not contain a cycle");
setError(error, "logic node ids must be unique within a logic");
return false;
}
return true;


+ 32
- 61
app/src/domain/control_logic_model.h 查看文件

@@ -3,122 +3,93 @@
#include "register_address.h"

#include <cstdint>
#include <optional>
#include <string>
#include <variant>
#include <vector>

// 逻辑节点在编辑画布中的持久化坐标
struct LogicPoint
{
static constexpr int kMinimumCoordinate = 0;
static constexpr int kMaximumCoordinate = 100000;

int x = 0;
int y = 0;

bool isValid() const;
};

// 触点工作方式
enum class ContactMode
{
NormallyOpen, // 常开触点
NormallyClosed // 常闭触点
NormallyOpen,
NormallyClosed
};

// 线圈工作方式
enum class CoilMode
{
Normal, // 普通线圈
Set, // 置位线圈
Reset // 复位线圈
Normal,
Set,
Reset
};

// 数值比较运算符
enum class ComparisonOperator
{
Equal, // 等于
NotEqual, // 不等于
LessThan, // 小于
LessThanOrEqual, // 小于等于
GreaterThan, // 大于
GreaterThanOrEqual // 大于等于
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual
};

// 描述读取 M 区位状态的触点节点配置
struct ContactNodeConfig
{
// 触点绑定的 M 区地址
RegisterAddress address{RegisterArea::M, 0};
// 触点工作方式
ContactMode mode = ContactMode::NormallyOpen;
};

// 描述向 M 区写入状态的线圈节点配置
struct CoilNodeConfig
{
// 线圈绑定的 M 区地址
RegisterAddress address{RegisterArea::M, 0};
// 线圈工作方式
CoilMode mode = CoilMode::Normal;
};

// 描述 D 区数值与固定值的比较节点配置
struct CompareNodeConfig
{
// 被比较的 D 区地址
RegisterAddress address{RegisterArea::D, 0};
// 比较运算符
ComparisonOperator comparison = ComparisonOperator::Equal;
// 参与比较的固定值
std::int16_t value = 0;
};

// 控制逻辑节点支持的配置类型
using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>;
// 描述控制逻辑中的一个功能节点

struct LogicNode
{
// 节点唯一标识
std::string id;
// 节点自身的业务配置
LogicNodeConfig config;
// 节点在逻辑编辑画布中的位置
LogicPoint position;

// 校验节点配置并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
bool isCondition() const;
bool isOutput() const;
};

// 描述两个控制逻辑节点之间的连接关系
struct LogicConnection
// 同一个串联级内的条件互为并联关系,任一条件成立即通过该级
struct LadderStage
{
// 连接起点的节点标识
std::string fromNodeId;
// 连接终点的节点标识
std::string toNodeId;
std::string id;
std::vector<LogicNode> branches;

// 校验连接配置并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
};

// 描述一组完整的控制逻辑及其节点和连接
// 梯级中的各级按顺序串联,输出线圈固定在最右侧
struct LadderRung
{
std::string id;
std::string name;
std::vector<LadderStage> stages;
std::optional<LogicNode> output;

bool validate(std::string *error = nullptr) const;
bool validateStructure(std::string *error = nullptr) const;
};

struct ControlLogic
{
// 控制逻辑唯一标识
std::string id;
// 控制逻辑显示名称
std::string name;
// 控制逻辑包含的节点集合
std::vector<LogicNode> nodes;
// 控制逻辑包含的连接集合
std::vector<LogicConnection> connections;
// 控制逻辑是否启用
std::vector<LadderRung> rungs;
bool enabled = true;

// 校验控制逻辑并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
// 校验编辑过程所需的结构规则,允许暂时没有完整输出
bool validateStructure(std::string *error = nullptr) const;
};

+ 125
- 98
app/src/infrastructure/json_project_storage.cpp 查看文件

@@ -211,38 +211,6 @@ QJsonObject serializeAddress(const RegisterAddress &address)
return object;
}

QJsonObject serializeLogicPoint(const LogicPoint &position)
{
QJsonObject object;
object.insert(QStringLiteral("x"), position.x);
object.insert(QStringLiteral("y"), position.y);
return object;
}

bool parseLogicPoint(
const QJsonObject &object,
const std::string &context,
LogicPoint *position,
ParseState *state)
{
return readInt(
object,
"x",
context,
LogicPoint::kMinimumCoordinate,
LogicPoint::kMaximumCoordinate,
&position->x,
state)
&& readInt(
object,
"y",
context,
LogicPoint::kMinimumCoordinate,
LogicPoint::kMaximumCoordinate,
&position->y,
state);
}

// 解析寄存器地址并校验区域名称及项目允许的索引范围
bool parseAddress(
const QJsonObject &object,
@@ -751,7 +719,6 @@ QJsonObject serializeLogicNode(const LogicNode &node)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(node.id));
object.insert(QStringLiteral("position"), serializeLogicPoint(node.position));
// std::visit 将不同节点配置统一转换为 config JSON 对象
object.insert(
QStringLiteral("config"),
@@ -847,124 +814,184 @@ bool parseLogicNode(
ParseState *state)
{
QJsonObject config;
QJsonObject position;
if (!readString(object, "id", context, &node->id, state)
|| !readObject(object, "config", context, &config, state)
|| !readObject(object, "position", context, &position, state)
|| !parseNodeConfig(config, context + ".config", &node->config, state))
{
return false;
}
return parseLogicPoint(
position,
context + ".position",
&node->position,
state);
return true;
}

// 将逻辑节点之间的有向连接序列化为 JSON 对象
QJsonObject serializeConnection(const LogicConnection &connection)
QJsonObject serializeLadderStage(const LadderStage &stage)
{
QJsonArray branches;
for (const LogicNode &node : stage.branches)
{
branches.append(serializeLogicNode(node));
}
QJsonObject object;
object.insert(QStringLiteral("fromNodeId"), fromUtf8(connection.fromNodeId));
object.insert(QStringLiteral("toNodeId"), fromUtf8(connection.toNodeId));
object.insert(QStringLiteral("id"), fromUtf8(stage.id));
object.insert(QStringLiteral("branches"), branches);
return object;
}

// 解析逻辑连接的起点和终点节点标识
bool parseConnection(
bool parseLadderStage(
const QJsonObject &object,
const std::string &context,
LogicConnection *connection,
LadderStage *stage,
ParseState *state)
{
return readString(object, "fromNodeId", context, &connection->fromNodeId, state)
&& readString(object, "toNodeId", context, &connection->toNodeId, state);
QJsonArray branches;
if (!readString(object, "id", context, &stage->id, state)
|| !readArray(object, "branches", context, &branches, state))
{
return false;
}
stage->branches.reserve(static_cast<std::size_t>(branches.size()));
for (int index = 0; index < branches.size(); ++index)
{
if (!branches.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".branches items must be objects");
}
LogicNode node;
if (!parseLogicNode(
branches.at(index).toObject(),
context + ".branches[" + std::to_string(index) + ']',
&node,
state))
{
return false;
}
stage->branches.push_back(std::move(node));
}
return true;
}

// 将一套控制逻辑及其节点和连接序列化为 JSON 对象
QJsonObject serializeControlLogic(const ControlLogic &logic)
QJsonObject serializeLadderRung(const LadderRung &rung)
{
QJsonArray nodes;
for (const LogicNode &node : logic.nodes)
QJsonArray stages;
for (const LadderStage &stage : rung.stages)
{
nodes.append(serializeLogicNode(node));
stages.append(serializeLadderStage(stage));
}
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(rung.id));
object.insert(QStringLiteral("name"), fromUtf8(rung.name));
object.insert(QStringLiteral("stages"), stages);
object.insert(
QStringLiteral("output"),
rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output))
: QJsonValue(QJsonValue::Null));
return object;
}

QJsonArray connections;
for (const LogicConnection &connection : logic.connections)
bool parseLadderRung(
const QJsonObject &object,
const std::string &context,
LadderRung *rung,
ParseState *state)
{
QJsonArray stages;
QJsonValue output;
if (!readString(object, "id", context, &rung->id, state)
|| !readString(object, "name", context, &rung->name, state)
|| !readArray(object, "stages", context, &stages, state)
|| !readValue(object, "output", context, &output, state))
{
return false;
}
rung->stages.reserve(static_cast<std::size_t>(stages.size()));
for (int index = 0; index < stages.size(); ++index)
{
if (!stages.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".stages items must be objects");
}
LadderStage stage;
if (!parseLadderStage(
stages.at(index).toObject(),
context + ".stages[" + std::to_string(index) + ']',
&stage,
state))
{
return false;
}
rung->stages.push_back(std::move(stage));
}
if (output.isNull())
{
rung->output.reset();
return true;
}
if (!output.isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".output must be an object or null");
}
LogicNode node;
if (!parseLogicNode(output.toObject(), context + ".output", &node, state))
{
connections.append(serializeConnection(connection));
return false;
}
rung->output = std::move(node);
return true;
}

QJsonObject serializeControlLogic(const ControlLogic &logic)
{
QJsonArray rungs;
for (const LadderRung &rung : logic.rungs)
{
rungs.append(serializeLadderRung(rung));
}
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(logic.id));
object.insert(QStringLiteral("name"), fromUtf8(logic.name));
object.insert(QStringLiteral("enabled"), logic.enabled);
object.insert(QStringLiteral("nodes"), nodes);
object.insert(QStringLiteral("connections"), connections);
object.insert(QStringLiteral("rungs"), rungs);
return object;
}

// 解析一套控制逻辑,并逐项构造节点和连接集合
bool parseControlLogic(
const QJsonObject &object,
const std::string &context,
ControlLogic *logic,
ParseState *state)
{
QJsonArray nodes;
QJsonArray connections;
QJsonArray rungs;
if (!readString(object, "id", context, &logic->id, state)
|| !readString(object, "name", context, &logic->name, state)
|| !readBool(object, "enabled", context, &logic->enabled, state)
|| !readArray(object, "nodes", context, &nodes, state)
|| !readArray(object, "connections", context, &connections, state))
|| !readArray(object, "rungs", context, &rungs, state))
{
return false;
}

// 数组元素必须是对象,错误上下文包含下标以便定位损坏数据
logic->nodes.reserve(static_cast<std::size_t>(nodes.size()));
for (int index = 0; index < nodes.size(); ++index)
{
if (!nodes.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".nodes items must be objects");
}
LogicNode node;
if (!parseLogicNode(
nodes.at(index).toObject(),
context + ".nodes[" + std::to_string(index) + ']',
&node,
state))
{
return false;
}
logic->nodes.push_back(std::move(node));
}

logic->connections.reserve(static_cast<std::size_t>(connections.size()));
for (int index = 0; index < connections.size(); ++index)
logic->rungs.reserve(static_cast<std::size_t>(rungs.size()));
for (int index = 0; index < rungs.size(); ++index)
{
if (!connections.at(index).isObject())
if (!rungs.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".connections items must be objects");
context + ".rungs items must be objects");
}
LogicConnection connection;
if (!parseConnection(
connections.at(index).toObject(),
context + ".connections[" + std::to_string(index) + ']',
&connection,
state))
LadderRung rung;
if (!parseLadderRung(
rungs.at(index).toObject(),
context + ".rungs[" + std::to_string(index) + ']',
&rung,
state))
{
return false;
}
logic->connections.push_back(std::move(connection));
logic->rungs.push_back(std::move(rung));
}
return true;
}


+ 340
- 203
app/src/services/logic_editor_service.cpp 查看文件

@@ -9,17 +9,12 @@

namespace {

bool validateCandidateNode(
const std::string &id,
const LogicNodeConfig &config,
const LogicPoint &position,
std::string *error)
LogicNode makeNode(const std::string &id, const LogicNodeConfig &config)
{
LogicNode candidate;
candidate.id = id;
candidate.config = config;
candidate.position = position;
return candidate.validate(error);
LogicNode node;
node.id = id;
node.config = config;
return node;
}

} // namespace
@@ -31,15 +26,33 @@ LogicEditorService::LogicEditorService(ProjectService &project_service)

const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const
{
const Project &project = project_service_.project();
const auto &logics = project_service_.project().controlLogics;
const auto logic = std::find_if(
project.controlLogics.cbegin(),
project.controlLogics.cend(),
logics.cbegin(),
logics.cend(),
[&logic_id](const ControlLogic &candidate)
{
return candidate.id == logic_id;
});
return logic == project.controlLogics.cend() ? nullptr : &*logic;
return logic == logics.cend() ? nullptr : &*logic;
}

const LadderRung *LogicEditorService::findRung(
const std::string &logic_id, const std::string &rung_id) const
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return nullptr;
}
const auto rung = std::find_if(
logic->rungs.cbegin(),
logic->rungs.cend(),
[&rung_id](const LadderRung &candidate)
{
return candidate.id == rung_id;
});
return rung == logic->rungs.cend() ? nullptr : &*rung;
}

const LogicNode *LogicEditorService::findNode(
@@ -50,20 +63,99 @@ const LogicNode *LogicEditorService::findNode(
{
return nullptr;
}
const auto node = std::find_if(
logic->nodes.cbegin(),
logic->nodes.cend(),
[&node_id](const LogicNode &candidate)
for (const LadderRung &rung : logic->rungs)
{
for (const LadderStage &stage : rung.stages)
{
return candidate.id == node_id;
});
return node == logic->nodes.cend() ? nullptr : &*node;
const auto node = std::find_if(
stage.branches.cbegin(),
stage.branches.cend(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
});
if (node != stage.branches.cend())
{
return &*node;
}
}
if (rung.output.has_value() && rung.output->id == node_id)
{
return &*rung.output;
}
}
return nullptr;
}

std::string LogicEditorService::firstLogicId() const
{
const Project &project = project_service_.project();
return project.controlLogics.empty() ? std::string{} : project.controlLogics.front().id;
const auto &logics = project_service_.project().controlLogics;
return logics.empty() ? std::string{} : logics.front().id;
}

std::string LogicEditorService::firstRungId(const std::string &logic_id) const
{
const ControlLogic *logic = findLogic(logic_id);
return logic == nullptr || logic->rungs.empty()
? std::string{} : logic->rungs.front().id;
}

std::string LogicEditorService::rungIdForNode(
const std::string &logic_id, const std::string &node_id) const
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return {};
}
for (const LadderRung &rung : logic->rungs)
{
if (rung.output.has_value() && rung.output->id == node_id)
{
return rung.id;
}
for (const LadderStage &stage : rung.stages)
{
if (std::any_of(
stage.branches.cbegin(),
stage.branches.cend(),
[&node_id](const LogicNode &node)
{
return node.id == node_id;
}))
{
return rung.id;
}
}
}
return {};
}

std::string LogicEditorService::stageIdForNode(
const std::string &logic_id, const std::string &node_id) const
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return {};
}
for (const LadderRung &rung : logic->rungs)
{
for (const LadderStage &stage : rung.stages)
{
if (std::any_of(
stage.branches.cbegin(),
stage.branches.cend(),
[&node_id](const LogicNode &node)
{
return node.id == node_id;
}))
{
return stage.id;
}
}
}
return {};
}

LogicEditorResult LogicEditorService::ensureDefaultLogic()
@@ -72,33 +164,25 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic()
{
return {true, LogicEditorError::None, {}, firstLogicId()};
}

ControlLogic logic;
logic.id = "logic-1";
logic.name = "控制逻辑 1";
logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt});
Project &project = project_service_.editProject();
project.controlLogics.push_back(std::move(logic));
return {true, LogicEditorError::None, {}, project.controlLogics.back().id};
}

LogicEditorResult LogicEditorService::addNode(
const std::string &logic_id,
const LogicNodeConfig &config,
const LogicPoint &position)
LogicEditorResult LogicEditorService::addRung(const std::string &logic_id)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}

const std::string id = makeUniqueNodeId(*logic, nodePrefix(config));
std::string error;
if (!validateCandidateNode(id, config, position, &error))
{
return failure(LogicEditorError::InvalidNode, error);
}

LadderRung rung;
rung.id = makeUniqueRungId(*logic);
rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U);
Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
@@ -107,230 +191,266 @@ LogicEditorResult LogicEditorService::addNode(
{
return candidate.id == logic_id;
});
LogicNode node;
node.id = id;
node.config = config;
node.position = position;
target->nodes.push_back(std::move(node));
return {true, LogicEditorError::None, {}, id};
target->rungs.push_back(std::move(rung));
return {true, LogicEditorError::None, {}, target->rungs.back().id};
}

LogicEditorResult LogicEditorService::moveNode(
const std::string &logic_id,
const std::string &node_id,
const LogicPoint &position)
LogicEditorResult LogicEditorService::removeRung(
const std::string &logic_id, const std::string &rung_id)
{
const ControlLogic *logic = findLogic(logic_id);
const LogicNode *node = findNode(logic_id, node_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (node == nullptr)
if (findRung(logic_id, rung_id) == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
return failure(LogicEditorError::RungNotFound, "ladder rung was not found");
}
if (!position.isValid())
if (logic->rungs.size() == 1U)
{
return failure(
LogicEditorError::InvalidNode,
"logic node position is outside the supported range");
return failure(LogicEditorError::InvalidOperation, "control logic requires one rung");
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate)
{
return candidate.id == logic_id;
});
auto editable = std::find_if(
target->nodes.begin(),
target->nodes.end(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
});
editable->position = position;
return {true, LogicEditorError::None, {}, node_id};
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
target->rungs.erase(
std::remove_if(
target->rungs.begin(), target->rungs.end(),
[&rung_id](const LadderRung &rung) { return rung.id == rung_id; }),
target->rungs.end());
return {true, LogicEditorError::None, {}, rung_id};
}

LogicEditorResult LogicEditorService::updateNodeConfig(
LogicEditorResult LogicEditorService::appendCondition(
const std::string &logic_id,
const std::string &node_id,
const std::string &rung_id,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
const LogicNode *node = findNode(logic_id, node_id);
const LadderRung *rung = findRung(logic_id, rung_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (node == nullptr)
if (rung == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
return failure(LogicEditorError::RungNotFound, "ladder rung was not found");
}
if (node->config.index() != config.index())
if (!isConditionConfig(config))
{
return failure(
LogicEditorError::UnsupportedNodeChange,
"node category cannot be changed after creation");
return failure(LogicEditorError::InvalidNode, "ladder condition cannot be a coil");
}

const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
LogicNode node = makeNode(node_id, config);
std::string error;
if (!validateCandidateNode(node_id, config, node->position, &error))
if (!node.validate(&error))
{
return failure(LogicEditorError::InvalidNode, error);
}

LadderStage stage;
stage.id = makeUniqueStageId(*rung);
stage.branches.push_back(std::move(node));
Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate)
{
return candidate.id == logic_id;
});
auto editable = std::find_if(
target->nodes.begin(),
target->nodes.end(),
[&node_id](const LogicNode &candidate)
{
return candidate.id == node_id;
});
editable->config = config;
auto &logics = project.controlLogics;
auto target_logic = std::find_if(
logics.begin(), logics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
auto target_rung = std::find_if(
target_logic->rungs.begin(), target_logic->rungs.end(),
[&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; });
target_rung->stages.push_back(std::move(stage));
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::connectNodes(
LogicEditorResult LogicEditorService::addParallelCondition(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id)
const std::string &rung_id,
const std::string &stage_id,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
const LadderRung *rung = findRung(logic_id, rung_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (findNode(logic_id, from_node_id) == nullptr
|| findNode(logic_id, to_node_id) == nullptr)
if (rung == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic connection node was not found");
return failure(LogicEditorError::RungNotFound, "ladder rung was not found");
}
if (hasConnection(*logic, from_node_id, to_node_id))
const auto stage = std::find_if(
rung->stages.cbegin(), rung->stages.cend(),
[&stage_id](const LadderStage &candidate) { return candidate.id == stage_id; });
if (stage == rung->stages.cend())
{
return failure(
LogicEditorError::DuplicateConnection,
"logic connection already exists");
return failure(LogicEditorError::StageNotFound, "ladder stage was not found");
}

ControlLogic candidate = *logic;
candidate.connections.push_back({from_node_id, to_node_id});
if (!isConditionConfig(config))
{
return failure(LogicEditorError::InvalidNode, "parallel branch requires a condition");
}
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
LogicNode node = makeNode(node_id, config);
std::string error;
if (!candidate.validateStructure(&error))
if (!node.validate(&error))
{
return failure(LogicEditorError::InvalidConnection, error);
return failure(LogicEditorError::InvalidNode, error);
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &item)
{
return item.id == logic_id;
});
target->connections.push_back({from_node_id, to_node_id});
return {true, LogicEditorError::None, {}, to_node_id};
auto target_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
auto target_rung = std::find_if(
target_logic->rungs.begin(), target_logic->rungs.end(),
[&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; });
auto target_stage = std::find_if(
target_rung->stages.begin(), target_rung->stages.end(),
[&stage_id](const LadderStage &candidate) { return candidate.id == stage_id; });
target_stage->branches.push_back(std::move(node));
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::disconnectNodes(
LogicEditorResult LogicEditorService::setOutput(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id)
const std::string &rung_id,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
const auto connection = std::find_if(
logic->connections.cbegin(),
logic->connections.cend(),
[&from_node_id, &to_node_id](const LogicConnection &candidate)
{
return candidate.fromNodeId == from_node_id
&& candidate.toNodeId == to_node_id;
});
if (connection == logic->connections.cend())
if (findRung(logic_id, rung_id) == nullptr)
{
return failure(LogicEditorError::InvalidConnection, "logic connection was not found");
return failure(LogicEditorError::RungNotFound, "ladder rung was not found");
}
if (!isOutputConfig(config))
{
return failure(LogicEditorError::InvalidNode, "ladder output must be a coil");
}
const LadderRung *rung = findRung(logic_id, rung_id);
const std::string node_id = rung->output.has_value()
? rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config));
LogicNode node = makeNode(node_id, config);
std::string error;
if (!node.validate(&error))
{
return failure(LogicEditorError::InvalidNode, error);
}
Project &project = project_service_.editProject();
auto target_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
auto target_rung = std::find_if(
target_logic->rungs.begin(), target_logic->rungs.end(),
[&rung_id](const LadderRung &candidate) { return candidate.id == rung_id; });
target_rung->output = std::move(node);
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config)
{
const LogicNode *node = findNode(logic_id, node_id);
if (node == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
}
if (node->config.index() != config.index())
{
return failure(
LogicEditorError::UnsupportedNodeChange,
"node category cannot be changed after creation");
}
LogicNode candidate = makeNode(node_id, config);
std::string error;
if (!candidate.validate(&error))
{
return failure(LogicEditorError::InvalidNode, error);
}
Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &item)
for (ControlLogic &logic : project.controlLogics)
{
if (logic.id != logic_id)
{
return item.id == logic_id;
});
target->connections.erase(
std::remove_if(
target->connections.begin(),
target->connections.end(),
[&from_node_id, &to_node_id](const LogicConnection &candidate)
continue;
}
for (LadderRung &rung : logic.rungs)
{
for (LadderStage &stage : rung.stages)
{
for (LogicNode &editable : stage.branches)
{
if (editable.id == node_id)
{
editable.config = config;
return {true, LogicEditorError::None, {}, node_id};
}
}
}
if (rung.output.has_value() && rung.output->id == node_id)
{
return candidate.fromNodeId == from_node_id
&& candidate.toNodeId == to_node_id;
}),
target->connections.end());
return {true, LogicEditorError::None, {}, to_node_id};
rung.output->config = config;
return {true, LogicEditorError::None, {}, node_id};
}
}
}
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
}

LogicEditorResult LogicEditorService::removeNode(
const std::string &logic_id, const std::string &node_id)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "control logic was not found");
}
if (findNode(logic_id, node_id) == nullptr)
{
return failure(LogicEditorError::NodeNotFound, "logic node was not found");
}

Project &project = project_service_.editProject();
auto target = std::find_if(
project.controlLogics.begin(),
project.controlLogics.end(),
[&logic_id](const ControlLogic &item)
for (ControlLogic &logic : project.controlLogics)
{
if (logic.id != logic_id)
{
return item.id == logic_id;
});
target->nodes.erase(
std::remove_if(
target->nodes.begin(),
target->nodes.end(),
[&node_id](const LogicNode &candidate)
continue;
}
for (LadderRung &rung : logic.rungs)
{
if (rung.output.has_value() && rung.output->id == node_id)
{
return candidate.id == node_id;
}),
target->nodes.end());
target->connections.erase(
std::remove_if(
target->connections.begin(),
target->connections.end(),
[&node_id](const LogicConnection &candidate)
rung.output.reset();
return {true, LogicEditorError::None, {}, node_id};
}
for (LadderStage &stage : rung.stages)
{
return candidate.fromNodeId == node_id || candidate.toNodeId == node_id;
}),
target->connections.end());
stage.branches.erase(
std::remove_if(
stage.branches.begin(), stage.branches.end(),
[&node_id](const LogicNode &node) { return node.id == node_id; }),
stage.branches.end());
}
rung.stages.erase(
std::remove_if(
rung.stages.begin(), rung.stages.end(),
[](const LadderStage &stage) { return stage.branches.empty(); }),
rung.stages.end());
}
}
return {true, LogicEditorError::None, {}, node_id};
}

bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config)
{
return !std::holds_alternative<CoilNodeConfig>(config);
}

bool LogicEditorService::isOutputConfig(const LogicNodeConfig &config)
{
return std::holds_alternative<CoilNodeConfig>(config);
}

std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config)
{
return std::visit(
@@ -341,11 +461,14 @@ std::string LogicEditorService::nodePrefix(const LogicNodeConfig &config)
{
return "contact";
}
if constexpr (std::is_same_v<Config, CoilNodeConfig>)
else if constexpr (std::is_same_v<Config, CoilNodeConfig>)
{
return "coil";
}
return "compare";
else
{
return "compare";
}
},
config);
}
@@ -355,37 +478,51 @@ std::string LogicEditorService::makeUniqueNodeId(
{
for (std::size_t index = 1;; ++index)
{
std::ostringstream stream;
stream << prefix << '-' << index;
const std::string candidate = stream.str();
const auto match = std::find_if(
logic.nodes.cbegin(),
logic.nodes.cend(),
[&candidate](const LogicNode &node)
const std::string candidate = prefix + '-' + std::to_string(index);
bool found = false;
for (const LadderRung &rung : logic.rungs)
{
found = (rung.output.has_value() && rung.output->id == candidate);
for (const LadderStage &stage : rung.stages)
{
return node.id == candidate;
});
if (match == logic.nodes.cend())
found = found || std::any_of(
stage.branches.cbegin(), stage.branches.cend(),
[&candidate](const LogicNode &node) { return node.id == candidate; });
}
}
if (!found)
{
return candidate;
}
}
}

bool LogicEditorService::hasConnection(
const ControlLogic &logic,
const std::string &from_node_id,
const std::string &to_node_id)
std::string LogicEditorService::makeUniqueRungId(const ControlLogic &logic)
{
return std::find_if(
logic.connections.cbegin(),
logic.connections.cend(),
[&from_node_id, &to_node_id](const LogicConnection &candidate)
{
return candidate.fromNodeId == from_node_id
&& candidate.toNodeId == to_node_id;
})
!= logic.connections.cend();
for (std::size_t index = 1;; ++index)
{
const std::string candidate = "rung-" + std::to_string(index);
if (std::none_of(
logic.rungs.cbegin(), logic.rungs.cend(),
[&candidate](const LadderRung &rung) { return rung.id == candidate; }))
{
return candidate;
}
}
}

std::string LogicEditorService::makeUniqueStageId(const LadderRung &rung)
{
for (std::size_t index = 1;; ++index)
{
const std::string candidate = "stage-" + std::to_string(index);
if (std::none_of(
rung.stages.cbegin(), rung.stages.cend(),
[&candidate](const LadderStage &stage) { return stage.id == candidate; }))
{
return candidate;
}
}
}

LogicEditorResult LogicEditorService::failure(


+ 28
- 20
app/src/services/logic_editor_service.h 查看文件

@@ -10,10 +10,11 @@ enum class LogicEditorError
{
None,
LogicNotFound,
RungNotFound,
StageNotFound,
NodeNotFound,
DuplicateConnection,
InvalidNode,
InvalidConnection,
InvalidOperation,
UnsupportedNodeChange
};

@@ -31,42 +32,49 @@ public:
explicit LogicEditorService(ProjectService &project_service);

const ControlLogic *findLogic(const std::string &logic_id) const;
const LadderRung *findRung(
const std::string &logic_id, const std::string &rung_id) const;
const LogicNode *findNode(
const std::string &logic_id, const std::string &node_id) const;
std::string firstLogicId() const;
std::string firstRungId(const std::string &logic_id) const;
std::string rungIdForNode(
const std::string &logic_id, const std::string &node_id) const;
std::string stageIdForNode(
const std::string &logic_id, const std::string &node_id) const;

LogicEditorResult ensureDefaultLogic();
LogicEditorResult addNode(
LogicEditorResult addRung(const std::string &logic_id);
LogicEditorResult removeRung(
const std::string &logic_id, const std::string &rung_id);
LogicEditorResult appendCondition(
const std::string &logic_id,
const LogicNodeConfig &config,
const LogicPoint &position);
LogicEditorResult moveNode(
const std::string &rung_id,
const LogicNodeConfig &config);
LogicEditorResult addParallelCondition(
const std::string &logic_id,
const std::string &node_id,
const LogicPoint &position);
const std::string &rung_id,
const std::string &stage_id,
const LogicNodeConfig &config);
LogicEditorResult setOutput(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config);
LogicEditorResult updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config);
LogicEditorResult connectNodes(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id);
LogicEditorResult disconnectNodes(
const std::string &logic_id,
const std::string &from_node_id,
const std::string &to_node_id);
LogicEditorResult removeNode(
const std::string &logic_id, const std::string &node_id);

private:
static bool isConditionConfig(const LogicNodeConfig &config);
static bool isOutputConfig(const LogicNodeConfig &config);
static std::string nodePrefix(const LogicNodeConfig &config);
static std::string makeUniqueNodeId(
const ControlLogic &logic, const std::string &prefix);
static bool hasConnection(
const ControlLogic &logic,
const std::string &from_node_id,
const std::string &to_node_id);
static std::string makeUniqueRungId(const ControlLogic &logic);
static std::string makeUniqueStageId(const LadderRung &rung);
static LogicEditorResult failure(
LogicEditorError error, const std::string &message);



+ 330
- 397
app/src/ui/logic_editor_widget.cpp 查看文件

@@ -1,95 +1,68 @@
#include "logic_editor_widget.h"

#include "services/logic_editor_service.h"

#include <QGraphicsPathItem>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QResizeEvent>
#include <QStyleOptionGraphicsItem>

#include <algorithm>
#include <cmath>
#include <functional>
#include <map>
#include <type_traits>
#include <utility>

namespace {

constexpr qreal kNodeWidth = 168.0;
constexpr qreal kNodeHeight = 72.0;
constexpr qreal kPortRadius = 6.0;
constexpr qreal kMinimumSceneWidth = 1280.0;
constexpr qreal kLeftRailX = 72.0;
constexpr qreal kStageWidth = 184.0;
constexpr qreal kNodeWidth = 118.0;
constexpr qreal kBranchSpacing = 58.0;
constexpr qreal kRungGap = 30.0;

QString nodeTitle(const LogicNodeConfig &config)
QString comparisonText(ComparisonOperator comparison)
{
return std::visit(
[](const auto &value) -> QString
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{
return value.mode == ContactMode::NormallyOpen
? QStringLiteral("常开触点")
: QStringLiteral("常闭触点");
}
if constexpr (std::is_same_v<Config, CoilNodeConfig>)
{
if (value.mode == CoilMode::Set)
{
return QStringLiteral("置位线圈");
}
if (value.mode == CoilMode::Reset)
{
return QStringLiteral("复位线圈");
}
return QStringLiteral("普通线圈");
}
return QStringLiteral("D 值比较");
},
config);
switch (comparison)
{
case ComparisonOperator::Equal: return QStringLiteral("=");
case ComparisonOperator::NotEqual: return QStringLiteral("<>");
case ComparisonOperator::LessThan: return QStringLiteral("<");
case ComparisonOperator::LessThanOrEqual: return QStringLiteral("<=");
case ComparisonOperator::GreaterThan: return QStringLiteral(">");
case ComparisonOperator::GreaterThanOrEqual: return QStringLiteral(">=");
default: return QStringLiteral("?");
}
}

QString nodeDetail(const LogicNodeConfig &config)
QString nodeAddressText(const LogicNodeConfig &config)
{
return std::visit(
[](const auto &value) -> QString
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{
return QStringLiteral("M%1").arg(value.address.index());
}
else if constexpr (std::is_same_v<Config, CoilNodeConfig>)
if constexpr (std::is_same_v<Config, ContactNodeConfig>
|| std::is_same_v<Config, CoilNodeConfig>)
{
return QStringLiteral("M%1").arg(value.address.index());
}
else
{
return QStringLiteral("D%1 = %2")
return QStringLiteral("D%1 %2 %3")
.arg(value.address.index())
.arg(comparisonText(value.comparison))
.arg(value.value);
}
},
config);
}

bool isCoil(const LogicNodeConfig &config)
qreal rungHeight(const LadderRung &rung)
{
return std::holds_alternative<CoilNodeConfig>(config);
}

QPainterPath makeConnectionPath(const QPointF &from, const QPointF &to)
{
QPainterPath path(from);
const qreal offset = std::max(48.0, std::abs(to.x() - from.x()) * 0.45);
path.cubicTo(
from + QPointF(offset, 0),
to - QPointF(offset, 0),
to);
return path;
std::size_t maximum_branches = 1U;
for (const LadderStage &stage : rung.stages)
{
maximum_branches = std::max(maximum_branches, stage.branches.size());
}
return 100.0 + static_cast<qreal>(maximum_branches - 1U) * kBranchSpacing;
}

} // namespace
@@ -99,22 +72,21 @@ class LogicEditorWidget::NodeItem final : public QGraphicsItem
public:
NodeItem(
const LogicNode &node,
std::function<void(const std::string &, const QPointF &)> moved,
std::function<void(const std::string &)> position_changed)
const std::string &rung_id,
const std::string &stage_id,
const QPointF &center)
: node_id_(node.id),
config_(node.config),
moved_(std::move(moved)),
position_changed_(std::move(position_changed))
rung_id_(rung_id),
stage_id_(stage_id),
config_(node.config)
{
setPos(node.position.x, node.position.y);
setPos(center);
setFlag(ItemIsSelectable, true);
setFlag(ItemSendsGeometryChanges, true);
setAcceptedMouseButtons(Qt::LeftButton);
}

QRectF boundingRect() const override
{
return {0, 0, kNodeWidth, kNodeHeight};
return {-kNodeWidth / 2.0, -27.0, kNodeWidth, 54.0};
}

void paint(
@@ -122,135 +94,115 @@ public:
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
const QRectF rect = boundingRect().adjusted(1, 1, -1, -1);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1));
painter->setBrush(isCoil(config_)
? QColor(QStringLiteral("#fff2d8"))
: QColor(QStringLiteral("#e8f1f6")));
painter->drawRoundedRect(rect, 5, 5);

painter->setPen(QColor(QStringLiteral("#18252e")));
painter->drawText(
rect.adjusted(14, 8, -14, -34),
Qt::AlignLeft | Qt::AlignVCenter,
nodeTitle(config_));
painter->setPen(QColor(QStringLiteral("#586873")));
painter->drawText(
rect.adjusted(14, 35, -14, -8),
Qt::AlignLeft | Qt::AlignVCenter,
nodeDetail(config_));

painter->setPen(QPen(QColor(QStringLiteral("#33424d")), 1));
painter->setBrush(QColor(QStringLiteral("#ffffff")));
painter->drawEllipse(inputPortRect());
if (!isCoil(config_))
const bool selected = (option->state & QStyle::State_Selected) != 0;
if (selected)
{
painter->drawEllipse(outputPortRect());
painter->fillRect(
boundingRect(), QColor(QStringLiteral("#dceef7")));
}
painter->setPen(QPen(
selected ? QColor(QStringLiteral("#1677a8"))
: QColor(QStringLiteral("#1f2b33")),
selected ? 2.0 : 1.6));

if ((option->state & QStyle::State_Selected) != 0)
if (const auto *contact = std::get_if<ContactNodeConfig>(&config_))
{
painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2));
painter->drawRoundedRect(boundingRect().adjusted(0, 0, -1, -1), 5, 5);
painter->drawLine(QPointF(-18, -15), QPointF(-18, 15));
painter->drawLine(QPointF(18, -15), QPointF(18, 15));
if (contact->mode == ContactMode::NormallyClosed)
{
painter->drawLine(QPointF(-23, 18), QPointF(23, -18));
}
}
}

const std::string &nodeId() const
{
return node_id_;
}

bool hasOutput() const
{
return !isCoil(config_);
}

QRectF inputPortRect() const
{
return {0, kNodeHeight / 2.0 - kPortRadius,
2.0 * kPortRadius, 2.0 * kPortRadius};
}

QRectF outputPortRect() const
{
return {kNodeWidth - 2.0 * kPortRadius, kNodeHeight / 2.0 - kPortRadius,
2.0 * kPortRadius, 2.0 * kPortRadius};
}

QPointF inputPortScenePosition() const
{
return mapToScene(inputPortRect().center());
}

QPointF outputPortScenePosition() const
{
return mapToScene(outputPortRect().center());
}

void setInteractionEnabled(bool enabled)
{
setFlag(ItemIsMovable, enabled);
}

protected:
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override
{
if (change == ItemPositionHasChanged && position_changed_)
else if (const auto *coil = std::get_if<CoilNodeConfig>(&config_))
{
position_changed_(node_id_);
painter->drawArc(QRectF(-29, -20, 30, 40), -80 * 16, 160 * 16);
painter->drawArc(QRectF(-1, -20, 30, 40), 100 * 16, 160 * 16);
if (coil->mode == CoilMode::Set || coil->mode == CoilMode::Reset)
{
painter->drawText(
QRectF(-12, -14, 24, 28),
Qt::AlignCenter,
coil->mode == CoilMode::Set ? QStringLiteral("S")
: QStringLiteral("R"));
}
}
return QGraphicsItem::itemChange(change, value);
}

void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override
{
QGraphicsItem::mouseReleaseEvent(event);
if (flags().testFlag(ItemIsMovable) && moved_)
else
{
moved_(node_id_, pos());
painter->drawRect(QRectF(-43, -18, 86, 36));
}
painter->setPen(QColor(QStringLiteral("#263640")));
painter->drawText(
QRectF(-kNodeWidth / 2.0, -42, kNodeWidth, 18),
Qt::AlignCenter,
nodeAddressText(config_));
}

const std::string &nodeId() const { return node_id_; }
const std::string &rungId() const { return rung_id_; }
const std::string &stageId() const { return stage_id_; }

private:
std::string node_id_;
std::string rung_id_;
std::string stage_id_;
LogicNodeConfig config_;
std::function<void(const std::string &, const QPointF &)> moved_;
std::function<void(const std::string &)> position_changed_;
};

class LogicEditorWidget::ConnectionItem final : public QGraphicsPathItem
class LogicEditorWidget::RungItem final : public QGraphicsItem
{
public:
ConnectionItem(
const std::string &from_node_id,
const std::string &to_node_id)
: from_node_id_(from_node_id), to_node_id_(to_node_id)
{
RungItem(
const LadderRung &rung,
int number,
qreal top,
qreal height,
qreal scene_width)
: rung_id_(rung.id),
name_(QString::fromUtf8(rung.name.data(), static_cast<int>(rung.name.size()))),
number_(number),
height_(height),
scene_width_(scene_width)
{
setPos(0, top);
setFlag(ItemIsSelectable, true);
setZValue(-1.0);
setPen(QPen(QColor(QStringLiteral("#5d6f7b")), 2));
setZValue(-2.0);
}

const std::string &fromNodeId() const
QRectF boundingRect() const override
{
return from_node_id_;
return {20, 0, scene_width_ - 40, height_};
}

const std::string &toNodeId() const
void paint(
QPainter *painter,
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
return to_node_id_;
if ((option->state & QStyle::State_Selected) != 0)
{
painter->fillRect(
boundingRect(), QColor(QStringLiteral("#f0f7fa")));
}
painter->setPen(QColor(QStringLiteral("#62717b")));
painter->drawText(
QRectF(28, 6, 260, 22),
Qt::AlignLeft | Qt::AlignVCenter,
QStringLiteral("%1 %2").arg(number_).arg(name_));
painter->setPen(QPen(QColor(QStringLiteral("#d4dce1")), 1));
painter->drawLine(
QPointF(28, height_ - 1), QPointF(scene_width_ - 28, height_ - 1));
}

void updatePath(const QPointF &from, const QPointF &to)
{
setPath(makeConnectionPath(from, to));
}
const std::string &rungId() const { return rung_id_; }

private:
std::string from_node_id_;
std::string to_node_id_;
std::string rung_id_;
QString name_;
int number_ = 0;
qreal height_ = 0;
qreal scene_width_ = 0;
};

LogicEditorWidget::LogicEditorWidget(
@@ -259,12 +211,11 @@ LogicEditorWidget::LogicEditorWidget(
: QGraphicsView(parent), editor_service_(editor_service)
{
scene_ = new QGraphicsScene(this);
scene_->setSceneRect(0, 0, 2400, 1400);
setScene(scene_);
setRenderHint(QPainter::Antialiasing, true);
setBackgroundBrush(QColor(QStringLiteral("#ffffff")));
setDragMode(QGraphicsView::RubberBandDrag);
setTransformationAnchor(QGraphicsView::AnchorUnderMouse);
setAlignment(Qt::AlignLeft | Qt::AlignTop);
connect(scene_, &QGraphicsScene::selectionChanged,
this, &LogicEditorWidget::handleSelectionChanged);
}
@@ -276,75 +227,124 @@ void LogicEditorWidget::setLogicId(const std::string &logic_id)
return;
}
logic_id_ = logic_id;
current_rung_id_.clear();
reloadLogic();
}

void LogicEditorWidget::setEditingEnabled(bool enabled)
{
editing_enabled_ = enabled;
setDragMode(enabled ? QGraphicsView::RubberBandDrag : QGraphicsView::NoDrag);
for (QGraphicsItem *item : scene_->items())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr)
{
node->setInteractionEnabled(enabled);
}
}
if (!enabled)
{
clearPreview();
}
setInteractive(enabled);
}

void LogicEditorWidget::reloadLogic()
{
clearPreview();
scene_->clear();
if (logic_id_.empty())
{
return;
}
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
if (logic == nullptr)
{
scene_->setSceneRect(0, 0, kMinimumSceneWidth, 400);
return;
}

std::map<std::string, NodeItem *> node_items;
for (const LogicConnection &connection : logic->connections)
std::size_t maximum_stage_count = 0U;
for (const LadderRung &rung : logic->rungs)
{
scene_->addItem(new ConnectionItem(connection.fromNodeId, connection.toNodeId));
maximum_stage_count = std::max(maximum_stage_count, rung.stages.size());
}
for (const LogicNode &node : logic->nodes)
const qreal scene_width = std::max(
kMinimumSceneWidth,
kLeftRailX + static_cast<qreal>(maximum_stage_count) * kStageWidth + 300.0);
const qreal right_rail_x = scene_width - 72.0;
if (current_rung_id_.empty() && !logic->rungs.empty())
{
auto *item = new NodeItem(
node,
[this](const std::string &node_id, const QPointF &position)
current_rung_id_ = logic->rungs.front().id;
}

qreal top = 24.0;
int rung_number = 1;
for (const LadderRung &rung : logic->rungs)
{
const qreal height = rungHeight(rung);
scene_->addItem(new RungItem(rung, rung_number, top, height, scene_width));
const qreal main_y = top + 62.0;

scene_->addLine(
kLeftRailX, top + 32.0, kLeftRailX, top + height - 18.0,
QPen(QColor(QStringLiteral("#202a31")), 3));
scene_->addLine(
right_rail_x, top + 32.0, right_rail_x, top + height - 18.0,
QPen(QColor(QStringLiteral("#202a31")), 3));

qreal cursor_x = kLeftRailX;
for (const LadderStage &stage : rung.stages)
{
const qreal next_x = cursor_x + kStageWidth;
const qreal left_join = cursor_x + 18.0;
const qreal right_join = next_x - 18.0;
scene_->addLine(
cursor_x, main_y, left_join, main_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
scene_->addLine(
right_join, main_y, next_x, main_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
if (stage.branches.size() > 1U)
{
handleNodePositionChanged(node_id);
const LogicPoint point{
static_cast<int>(std::lround(position.x())),
static_cast<int>(std::lround(position.y()))};
const LogicEditorResult result = editor_service_.moveNode(
logic_id_, node_id, point);
if (!result.succeeded)
{
reportFailure(result);
reloadLogic();
return;
}
emit nodeChanged(QString::fromStdString(node_id));
},
[this](const std::string &)
const qreal bottom_y = main_y
+ static_cast<qreal>(stage.branches.size() - 1U) * kBranchSpacing;
scene_->addLine(
left_join, main_y, left_join, bottom_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
scene_->addLine(
right_join, main_y, right_join, bottom_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
}
for (std::size_t branch = 0; branch < stage.branches.size(); ++branch)
{
updateConnections();
});
item->setInteractionEnabled(editing_enabled_);
scene_->addItem(item);
node_items.emplace(node.id, item);
const qreal branch_y = main_y + static_cast<qreal>(branch) * kBranchSpacing;
scene_->addLine(
left_join, branch_y, right_join, branch_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
scene_->addItem(new NodeItem(
stage.branches.at(branch),
rung.id,
stage.id,
QPointF((left_join + right_join) / 2.0, branch_y)));
}
cursor_x = next_x;
}

const qreal output_x = right_rail_x - 116.0;
scene_->addLine(
cursor_x, main_y, output_x, main_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
scene_->addLine(
output_x, main_y, right_rail_x, main_y,
QPen(QColor(QStringLiteral("#202a31")), 2));
if (rung.output.has_value())
{
scene_->addItem(new NodeItem(
*rung.output,
rung.id,
{},
QPointF(output_x + 50.0, main_y)));
}
else
{
auto *placeholder = scene_->addText(QStringLiteral("< 输出线圈 >"));
placeholder->setDefaultTextColor(QColor(QStringLiteral("#87949c")));
placeholder->setPos(output_x + 4.0, main_y - 14.0);
}
if (rung.stages.empty())
{
auto *placeholder = scene_->addText(QStringLiteral("< 添加条件 >"));
placeholder->setDefaultTextColor(QColor(QStringLiteral("#87949c")));
placeholder->setPos(kLeftRailX + 32.0, main_y - 14.0);
}
top += height + kRungGap;
++rung_number;
}
updateConnections();
scene_->setSceneRect(0, 0, scene_width, std::max(400.0, top + 20.0));
}

void LogicEditorWidget::selectNode(const std::string &node_id)
@@ -376,249 +376,182 @@ std::string LogicEditorWidget::selectedNodeId() const
return {};
}

LogicEditorResult LogicEditorWidget::addNode(const LogicNodeConfig &config)
{
if (logic_id_.empty())
{
const LogicEditorResult logic_result = editor_service_.ensureDefaultLogic();
if (!logic_result.succeeded)
{
reportFailure(logic_result);
return logic_result;
}
logic_id_ = logic_result.id;
}
const LogicEditorResult result = editor_service_.addNode(
logic_id_, config, nextNodePosition());
if (!result.succeeded)
{
reportFailure(result);
return result;
}
reloadLogic();
selectNode(result.id);
emit nodeChanged(QString::fromStdString(result.id));
emit graphChanged();
return result;
}

LogicEditorResult LogicEditorWidget::deleteSelected()
std::string LogicEditorWidget::selectedRungId() const
{
for (QGraphicsItem *item : scene_->selectedItems())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
const NodeItem *node = dynamic_cast<const NodeItem *>(item);
if (node != nullptr)
{
const LogicEditorResult result = editor_service_.removeNode(
logic_id_, node->nodeId());
if (!result.succeeded)
{
reportFailure(result);
return result;
}
reloadLogic();
emit nodeSelected({});
emit graphChanged();
return result;
return node->rungId();
}
ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item);
if (connection != nullptr)
const RungItem *rung = dynamic_cast<const RungItem *>(item);
if (rung != nullptr)
{
const LogicEditorResult result = editor_service_.disconnectNodes(
logic_id_, connection->fromNodeId(), connection->toNodeId());
if (!result.succeeded)
{
reportFailure(result);
return result;
}
reloadLogic();
emit graphChanged();
return result;
return rung->rungId();
}
}
return {false, LogicEditorError::NodeNotFound, "no logic object is selected", {}};
return {};
}

void LogicEditorWidget::mousePressEvent(QMouseEvent *event)
std::string LogicEditorWidget::selectedStageId() const
{
if (editing_enabled_ && event->button() == Qt::LeftButton)
for (QGraphicsItem *item : scene_->selectedItems())
{
NodeItem *output = nodeAtPort(mapToScene(event->pos()), true);
if (output != nullptr)
const NodeItem *node = dynamic_cast<const NodeItem *>(item);
if (node != nullptr)
{
connecting_from_node_id_ = output->nodeId();
preview_item_ = scene_->addPath(QPainterPath());
preview_item_->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2, Qt::DashLine));
preview_item_->setZValue(-0.5);
updatePreview(mapToScene(event->pos()));
event->accept();
return;
return node->stageId();
}
}
QGraphicsView::mousePressEvent(event);
return {};
}

void LogicEditorWidget::mouseMoveEvent(QMouseEvent *event)
LogicEditorResult LogicEditorWidget::addRung()
{
if (!connecting_from_node_id_.empty())
const LogicEditorResult result = editor_service_.addRung(logic_id_);
if (result.succeeded)
{
updatePreview(mapToScene(event->pos()));
event->accept();
return;
current_rung_id_ = result.id;
reloadLogic();
emit graphChanged();
}
QGraphicsView::mouseMoveEvent(event);
}

void LogicEditorWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (!connecting_from_node_id_.empty())
else
{
NodeItem *input = nodeAtPort(mapToScene(event->pos()), false);
const std::string from = connecting_from_node_id_;
clearPreview();
if (input != nullptr && input->nodeId() != from)
{
const LogicEditorResult result = editor_service_.connectNodes(
logic_id_, from, input->nodeId());
if (!result.succeeded)
{
reportFailure(result);
}
else
{
reloadLogic();
emit graphChanged();
}
}
event->accept();
return;
reportFailure(result);
}
QGraphicsView::mouseReleaseEvent(event);
}

void LogicEditorWidget::resizeEvent(QResizeEvent *event)
{
QGraphicsView::resizeEvent(event);
updateConnections();
}

void LogicEditorWidget::handleSelectionChanged()
{
emit nodeSelected(QString::fromStdString(selectedNodeId()));
}

void LogicEditorWidget::handleNodePositionChanged(const std::string &)
{
updateConnections();
return result;
}

void LogicEditorWidget::updateConnections()
LogicEditorResult LogicEditorWidget::appendCondition(const LogicNodeConfig &config)
{
std::map<std::string, NodeItem *> node_items;
for (QGraphicsItem *item : scene_->items())
const LogicEditorResult result = editor_service_.appendCondition(
logic_id_, currentRungId(), config);
if (result.succeeded)
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr)
{
node_items.emplace(node->nodeId(), node);
}
reloadLogic();
selectNode(result.id);
emit graphChanged();
}
for (QGraphicsItem *item : scene_->items())
else
{
ConnectionItem *connection = dynamic_cast<ConnectionItem *>(item);
if (connection == nullptr)
{
continue;
}
const auto from = node_items.find(connection->fromNodeId());
const auto to = node_items.find(connection->toNodeId());
if (from != node_items.end() && to != node_items.end())
{
connection->updatePath(
from->second->outputPortScenePosition(),
to->second->inputPortScenePosition());
}
reportFailure(result);
}
return result;
}

void LogicEditorWidget::updatePreview(const QPointF &scene_position)
LogicEditorResult LogicEditorWidget::addParallelCondition(
const LogicNodeConfig &config)
{
if (preview_item_ == nullptr)
const std::string rung_id = selectedRungId();
const std::string stage_id = selectedStageId();
LogicEditorResult result;
if (rung_id.empty() || stage_id.empty())
{
return;
result = {false,
LogicEditorError::InvalidOperation,
"select a condition before adding a parallel branch",
{}};
}
for (QGraphicsItem *item : scene_->items())
else
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node != nullptr && node->nodeId() == connecting_from_node_id_)
{
preview_item_->setPath(
makeConnectionPath(node->outputPortScenePosition(), scene_position));
return;
}
result = editor_service_.addParallelCondition(
logic_id_, rung_id, stage_id, config);
}
if (result.succeeded)
{
reloadLogic();
selectNode(result.id);
emit graphChanged();
}
else
{
reportFailure(result);
}
return result;
}

void LogicEditorWidget::clearPreview()
LogicEditorResult LogicEditorWidget::setOutput(const LogicNodeConfig &config)
{
if (preview_item_ != nullptr)
const LogicEditorResult result = editor_service_.setOutput(
logic_id_, currentRungId(), config);
if (result.succeeded)
{
scene_->removeItem(preview_item_);
delete preview_item_;
preview_item_ = nullptr;
reloadLogic();
selectNode(result.id);
emit graphChanged();
}
else
{
reportFailure(result);
}
connecting_from_node_id_.clear();
return result;
}

LogicEditorWidget::NodeItem *LogicEditorWidget::nodeAtPort(
const QPointF &scene_position, bool output) const
LogicEditorResult LogicEditorWidget::deleteSelected()
{
for (QGraphicsItem *item : scene_->items())
const std::string node_id = selectedNodeId();
LogicEditorResult result;
if (!node_id.empty())
{
NodeItem *node = dynamic_cast<NodeItem *>(item);
if (node == nullptr || (output && !node->hasOutput()))
result = editor_service_.removeNode(logic_id_, node_id);
}
else
{
const std::string rung_id = selectedRungId();
if (rung_id.empty())
{
continue;
return {false, LogicEditorError::InvalidOperation, "no ladder object is selected", {}};
}
const QPointF port = output
? node->outputPortScenePosition()
: node->inputPortScenePosition();
if (QLineF(port, scene_position).length() <= 12.0)
result = editor_service_.removeRung(logic_id_, rung_id);
if (result.succeeded && current_rung_id_ == rung_id)
{
return node;
current_rung_id_ = editor_service_.firstRungId(logic_id_);
}
}
return nullptr;
if (result.succeeded)
{
reloadLogic();
emit nodeSelected({});
emit graphChanged();
}
else
{
reportFailure(result);
}
return result;
}

LogicPoint LogicEditorWidget::nextNodePosition() const
void LogicEditorWidget::resizeEvent(QResizeEvent *event)
{
const ControlLogic *logic = editor_service_.findLogic(logic_id_);
for (std::size_t index = 0;; ++index)
QGraphicsView::resizeEvent(event);
}

void LogicEditorWidget::handleSelectionChanged()
{
const std::string rung_id = selectedRungId();
if (!rung_id.empty())
{
const LogicPoint candidate{
80 + static_cast<int>(index % 5U) * 230,
80 + static_cast<int>(index / 5U) * 130};
if (logic == nullptr)
{
return candidate;
}
const auto occupied = std::find_if(
logic->nodes.cbegin(),
logic->nodes.cend(),
[&candidate](const LogicNode &node)
{
return std::abs(node.position.x - candidate.x) < 40
&& std::abs(node.position.y - candidate.y) < 40;
});
if (occupied == logic->nodes.cend())
{
return candidate;
}
current_rung_id_ = rung_id;
}
emit nodeSelected(QString::fromStdString(selectedNodeId()));
}

void LogicEditorWidget::reportFailure(const LogicEditorResult &result)
{
emit editorError(QString::fromStdString(result.message));
}

std::string LogicEditorWidget::currentRungId() const
{
const std::string selected = selectedRungId();
if (!selected.empty())
{
return selected;
}
if (!current_rung_id_.empty())
{
return current_rung_id_;
}
return editor_service_.firstRungId(logic_id_);
}

+ 11
- 18
app/src/ui/logic_editor_widget.h 查看文件

@@ -1,16 +1,13 @@
#pragma once

#include "domain/control_logic_model.h"
#include "services/logic_editor_service.h"

#include <QGraphicsView>

#include <string>

class LogicEditorService;
struct LogicEditorResult;
class QGraphicsPathItem;
class QGraphicsScene;
class QMouseEvent;
class QResizeEvent;

class LogicEditorWidget final : public QGraphicsView
@@ -27,38 +24,34 @@ public:
void reloadLogic();
void selectNode(const std::string &node_id);
std::string selectedNodeId() const;
LogicEditorResult addNode(const LogicNodeConfig &config);
std::string selectedRungId() const;
std::string selectedStageId() const;

LogicEditorResult addRung();
LogicEditorResult appendCondition(const LogicNodeConfig &config);
LogicEditorResult addParallelCondition(const LogicNodeConfig &config);
LogicEditorResult setOutput(const LogicNodeConfig &config);
LogicEditorResult deleteSelected();

signals:
void nodeSelected(const QString &node_id);
void nodeChanged(const QString &node_id);
void graphChanged();
void editorError(const QString &message);

protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void resizeEvent(QResizeEvent *event) override;

private:
class NodeItem;
class ConnectionItem;
class RungItem;

void handleSelectionChanged();
void handleNodePositionChanged(const std::string &node_id);
void updateConnections();
void updatePreview(const QPointF &scene_position);
void clearPreview();
NodeItem *nodeAtPort(const QPointF &scene_position, bool output) const;
LogicPoint nextNodePosition() const;
void reportFailure(const LogicEditorResult &result);
std::string currentRungId() const;

LogicEditorService &editor_service_;
QGraphicsScene *scene_ = nullptr;
std::string logic_id_;
std::string current_rung_id_;
bool editing_enabled_ = true;
std::string connecting_from_node_id_;
QGraphicsPathItem *preview_item_ = nullptr;
};

+ 60
- 24
app/src/ui/main_window.cpp 查看文件

@@ -231,6 +231,12 @@ void MainWindow::configureActions()
logic_tool_bar_ = addToolBar(tr("控制逻辑节点"));
logic_tool_bar_->setObjectName(QStringLiteral("logicToolBar"));
logic_tool_bar_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
add_rung_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_FileIcon), tr("新建网络"));
parallel_insert_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_ArrowDown), tr("并联插入"));
parallel_insert_action_->setCheckable(true);
logic_tool_bar_->addSeparator();
add_normally_open_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_ArrowRight), tr("常开"));
add_normally_closed_action_ = logic_tool_bar_->addAction(
@@ -245,6 +251,8 @@ void MainWindow::configureActions()
style()->standardIcon(QStyle::SP_FileDialogInfoView), tr("D 比较"));
delete_logic_action_ = logic_tool_bar_->addAction(
style()->standardIcon(QStyle::SP_TrashIcon), tr("删除"));
add_rung_action_->setObjectName(QStringLiteral("addRungAction"));
parallel_insert_action_->setObjectName(QStringLiteral("parallelInsertAction"));
add_normally_open_action_->setObjectName(QStringLiteral("addNormallyOpenAction"));
add_normally_closed_action_->setObjectName(QStringLiteral("addNormallyClosedAction"));
add_normal_coil_action_->setObjectName(QStringLiteral("addNormalCoilAction"));
@@ -252,18 +260,22 @@ void MainWindow::configureActions()
add_reset_coil_action_->setObjectName(QStringLiteral("addResetCoilAction"));
add_compare_action_->setObjectName(QStringLiteral("addCompareAction"));
delete_logic_action_->setObjectName(QStringLiteral("deleteLogicAction"));
add_normally_open_action_->setToolTip(tr("添加常开触点"));
add_normally_closed_action_->setToolTip(tr("添加常闭触点"));
add_normal_coil_action_->setToolTip(tr("添加普通线圈"));
add_set_coil_action_->setToolTip(tr("添加置位线圈"));
add_reset_coil_action_->setToolTip(tr("添加复位线圈"));
add_compare_action_->setToolTip(tr("添加 D 值与常量比较"));
delete_logic_action_->setToolTip(tr("删除选中的逻辑节点或连接"));
add_rung_action_->setToolTip(tr("在梯形图末尾新增网络"));
parallel_insert_action_->setToolTip(tr("将下一个条件并联到当前选中的条件"));
add_normally_open_action_->setToolTip(tr("向当前网络添加常开触点"));
add_normally_closed_action_->setToolTip(tr("向当前网络添加常闭触点"));
add_normal_coil_action_->setToolTip(tr("设置当前网络的普通线圈"));
add_set_coil_action_->setToolTip(tr("设置当前网络的置位线圈"));
add_reset_coil_action_->setToolTip(tr("设置当前网络的复位线圈"));
add_compare_action_->setToolTip(tr("向当前网络添加 D 值与常量比较"));
delete_logic_action_->setToolTip(tr("删除选中的逻辑节点或网络"));
connect(add_rung_action_, &QAction::triggered,
this, &MainWindow::addLogicRung);
connect(add_normally_open_action_, &QAction::triggered,
this,
[this]
{
addLogicNode(ContactNodeConfig{
addLogicCondition(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen});
});
@@ -271,7 +283,7 @@ void MainWindow::configureActions()
this,
[this]
{
addLogicNode(ContactNodeConfig{
addLogicCondition(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyClosed});
});
@@ -279,7 +291,7 @@ void MainWindow::configureActions()
this,
[this]
{
addLogicNode(CoilNodeConfig{
setLogicOutput(CoilNodeConfig{
RegisterAddress{RegisterArea::M, 0},
CoilMode::Normal});
});
@@ -287,7 +299,7 @@ void MainWindow::configureActions()
this,
[this]
{
addLogicNode(CoilNodeConfig{
setLogicOutput(CoilNodeConfig{
RegisterAddress{RegisterArea::M, 0},
CoilMode::Set});
});
@@ -295,7 +307,7 @@ void MainWindow::configureActions()
this,
[this]
{
addLogicNode(CoilNodeConfig{
setLogicOutput(CoilNodeConfig{
RegisterAddress{RegisterArea::M, 0},
CoilMode::Reset});
});
@@ -303,7 +315,7 @@ void MainWindow::configureActions()
this,
[this]
{
addLogicNode(CompareNodeConfig{
addLogicCondition(CompareNodeConfig{
RegisterAddress{RegisterArea::D, 0},
ComparisonOperator::Equal,
0});
@@ -435,13 +447,6 @@ void MainWindow::configureLogicEditor()
{
showLogicNodeProperties(toUtf8(id));
});
connect(logic_editor_widget_, &LogicEditorWidget::nodeChanged,
this,
[this](const QString &id)
{
showLogicNodeProperties(toUtf8(id));
refreshProjectUi();
});
connect(logic_editor_widget_, &LogicEditorWidget::graphChanged,
this, &MainWindow::refreshProjectUi);
connect(logic_editor_widget_, &LogicEditorWidget::editorError,
@@ -802,18 +807,49 @@ void MainWindow::applySelectedControlProperties()
statusBar()->showMessage(tr("控件属性已更新"), 3000);
}

void MainWindow::addLogicNode(const LogicNodeConfig &config)
void MainWindow::addLogicCondition(const LogicNodeConfig &config)
{
const LogicEditorResult result = parallel_insert_action_->isChecked()
? logic_editor_widget_->addParallelCondition(config)
: logic_editor_widget_->appendCondition(config);
if (!result.succeeded)
{
showProjectResult(tr("添加逻辑条件"), fromUtf8(result.message), false);
return;
}
parallel_insert_action_->setChecked(false);
selected_logic_node_id_ = result.id;
showLogicNodeProperties(result.id);
refreshProjectUi();
statusBar()->showMessage(tr("已添加逻辑条件"), 3000);
}

void MainWindow::setLogicOutput(const LogicNodeConfig &config)
{
const LogicEditorResult result = logic_editor_widget_->addNode(config);
const LogicEditorResult result = logic_editor_widget_->setOutput(config);
if (!result.succeeded)
{
showProjectResult(tr("添加逻辑节点"), fromUtf8(result.message), false);
showProjectResult(tr("设置输出线圈"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_ = result.id;
showLogicNodeProperties(result.id);
refreshProjectUi();
statusBar()->showMessage(tr("已添加逻辑节点"), 3000);
statusBar()->showMessage(tr("输出线圈已设置"), 3000);
}

void MainWindow::addLogicRung()
{
const LogicEditorResult result = logic_editor_widget_->addRung();
if (!result.succeeded)
{
showProjectResult(tr("新建网络"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
statusBar()->showMessage(tr("已新建网络"), 3000);
}

void MainWindow::deleteSelectedLogicObject()


+ 9
- 3
app/src/ui/main_window.h 查看文件

@@ -92,9 +92,13 @@ private:
void deleteSelectedControl();
// 将属性表单内容应用到当前选中控件
void applySelectedControlProperties();
// 向当前控制逻辑添加指定配置的节点
void addLogicNode(const LogicNodeConfig &config);
// 删除当前选中的逻辑节点或连接
// 向当前网络添加串联条件,或按当前插入模式添加并联条件
void addLogicCondition(const LogicNodeConfig &config);
// 设置当前网络右侧的输出线圈
void setLogicOutput(const LogicNodeConfig &config);
// 新增一个梯形图网络
void addLogicRung();
// 删除当前选中的逻辑节点或网络
void deleteSelectedLogicObject();
// 将逻辑属性表单内容应用到当前选中节点
void applySelectedLogicNodeProperties();
@@ -150,6 +154,8 @@ private:
QAction *add_numeric_display_action_ = nullptr;
QAction *add_numeric_input_action_ = nullptr;
QAction *delete_control_action_ = nullptr;
QAction *add_rung_action_ = nullptr;
QAction *parallel_insert_action_ = nullptr;
QAction *add_normally_open_action_ = nullptr;
QAction *add_normally_closed_action_ = nullptr;
QAction *add_normal_coil_action_ = nullptr;


+ 46
- 28
app/tests/domain_tests.cpp 查看文件

@@ -56,7 +56,7 @@ void testRegisterRepositorySeparatesAreas()

Project makeValidProject()
{
// 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线
// 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线
HmiControl start_button;
start_button.id = "start-button";
start_button.type = HmiControlType::Button;
@@ -83,8 +83,15 @@ Project makeValidProject()
ControlLogic logic;
logic.id = "start-logic";
logic.name = "Start logic";
logic.nodes = {contact, coil};
logic.connections.push_back({contact.id, coil.id});
LadderStage stage;
stage.id = "stage-1";
stage.branches.push_back(contact);
LadderRung rung;
rung.id = "rung-1";
rung.name = "Network 1";
rung.stages.push_back(stage);
rung.output = coil;
logic.rungs.push_back(rung);

Project project;
project.metadata = {"sample-project", "Sample project", "1.0"};
@@ -117,8 +124,14 @@ void testLogicNodeConfigurationBoundaries()
require(comparison.validate(), "comparison node bound to D address must be valid");
}

void testLogicGraphBoundaries()
void testLadderLogicBoundaries()
{
LogicNode stop;
stop.id = "stop";
stop.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyClosed};

LogicNode start;
start.id = "start";
start.config = ContactNodeConfig{
@@ -140,29 +153,34 @@ void testLogicGraphBoundaries()
ControlLogic logic;
logic.id = "hold-logic";
logic.name = "Hold logic";
logic.nodes = {start, run_contact, coil};
logic.connections = {
{start.id, coil.id},
{run_contact.id, coil.id}};
require(logic.validate(), "a branched self-hold graph must be valid");

logic.connections.push_back({start.id, coil.id});
require(!logic.validate(), "duplicate logic connections must be rejected");

logic.connections.pop_back();
logic.connections.push_back({coil.id, start.id});
require(!logic.validate(), "coil outgoing connections must be rejected");

logic.connections.pop_back();
logic.connections.push_back({start.id, run_contact.id});
logic.connections.push_back({run_contact.id, start.id});
require(!logic.validate(), "logic connection cycles must be rejected");

logic.connections.pop_back();
logic.connections.pop_back();
logic.connections.clear();
logic.connections.push_back({start.id, coil.id});
require(!logic.validate(), "a disconnected condition node must be rejected");
LadderRung rung;
rung.id = "rung-1";
rung.name = "Self hold";
rung.stages.push_back({"stage-stop", {stop}});
rung.stages.push_back({"stage-start", {start, run_contact}});
rung.output = coil;
logic.rungs.push_back(rung);
require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid");

logic.rungs.front().stages.front().branches.push_back(coil);
require(!logic.validate(), "a ladder condition stage must reject coils");

logic.rungs.front().stages.front().branches.pop_back();
logic.rungs.front().output = start;
require(!logic.validate(), "a ladder output must be a coil");

logic.rungs.front().output.reset();
require(!logic.validate(), "conditions without an output must fail full validation");

LadderRung empty_rung{"rung-empty", "Empty network", {}, std::nullopt};
require(empty_rung.validate(), "an empty editing network must be valid");

empty_rung.output = coil;
require(!empty_rung.validate(), "an output without conditions must be rejected");

logic.rungs.front().output = coil;
logic.rungs.front().stages.at(1).branches.at(1).id = start.id;
require(!logic.validate(), "logic node ids must be unique");
}

void testModelsValidateBindingsAndIdentifiers()
@@ -225,7 +243,7 @@ int main()
testRegisterAddressBoundaries();
testRegisterRepositorySeparatesAreas();
testLogicNodeConfigurationBoundaries();
testLogicGraphBoundaries();
testLadderLogicBoundaries();
testModelsValidateBindingsAndIdentifiers();
testRuntimeStateBoundaries();
}


+ 54
- 25
app/tests/logic_editor_service_tests.cpp 查看文件

@@ -39,33 +39,53 @@ void testEditorOperations()
require(logic_result.succeeded, "default logic must be created");
const std::string logic_id = logic_result.id;

const LogicEditorResult start_result = service.addNode(
const std::string rung_id = service.firstRungId(logic_id);
require(!rung_id.empty(), "default logic must contain an editable rung");

const LogicEditorResult stop_result = service.appendCondition(
logic_id,
rung_id,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyClosed});
const LogicEditorResult start_result = service.appendCondition(
logic_id,
rung_id,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen},
{80, 80});
const LogicEditorResult coil_result = service.addNode(
ContactMode::NormallyOpen});
require(stop_result.succeeded && start_result.succeeded,
"series contacts must be appended as ladder stages");

const std::string start_stage_id = service.stageIdForNode(
logic_id, start_result.id);
const LogicEditorResult hold_result = service.addParallelCondition(
logic_id,
rung_id,
start_stage_id,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 2},
ContactMode::NormallyOpen});
const LogicEditorResult coil_result = service.setOutput(
logic_id,
rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1},
CoilMode::Normal},
{360, 80});
require(start_result.succeeded && coil_result.succeeded,
"contact and coil must be added");

require(service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded,
"valid nodes must connect");
require(!service.connectNodes(logic_id, start_result.id, coil_result.id).succeeded,
"duplicate node connections must fail");
require(!service.connectNodes(logic_id, coil_result.id, start_result.id).succeeded,
"coil outgoing connection must fail");
RegisterAddress{RegisterArea::M, 2},
CoilMode::Normal});
require(hold_result.succeeded && coil_result.succeeded,
"parallel hold contact and output coil must be added");

const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->stages.size() == 2,
"series conditions must occupy ordered stages");
require(rung->stages.at(1).branches.size() == 2,
"parallel conditions must share one stage");
require(rung->output.has_value(), "rung output must be fixed separately");
require(service.findLogic(logic_id)->validate(),
"configured self-hold ladder must pass full validation");

const LogicNode *start = service.findNode(logic_id, start_result.id);
require(start != nullptr && start->position.x == 80,
"added node position must be stored");
require(service.moveNode(logic_id, start_result.id, {120, 160}).succeeded,
"node position must be editable");
require(start != nullptr, "added condition must be discoverable");
require(service.updateNodeConfig(
logic_id,
start_result.id,
@@ -75,12 +95,21 @@ void testEditorOperations()
.succeeded,
"contact properties must be editable");

require(service.removeNode(logic_id, hold_result.id).succeeded,
"parallel branch deletion must succeed");
require(service.findRung(logic_id, rung_id)->stages.at(1).branches.size() == 1,
"deleting one branch must keep the ladder stage");
require(service.removeNode(logic_id, start_result.id).succeeded,
"node deletion must succeed");
require(service.findNode(logic_id, start_result.id) == nullptr,
"deleted node must disappear");
require(service.findLogic(logic_id)->connections.empty(),
"deleting a node must remove its connections");
"last branch deletion must succeed");
require(service.findRung(logic_id, rung_id)->stages.size() == 1,
"deleting the last branch must remove the empty stage");

const LogicEditorResult second_rung = service.addRung(logic_id);
require(second_rung.succeeded, "additional ladder rungs must be supported");
require(service.removeRung(logic_id, second_rung.id).succeeded,
"additional ladder rungs must be removable");
require(!service.removeRung(logic_id, rung_id).succeeded,
"the only remaining ladder rung must not be removed");
}

} // namespace


+ 11
- 2
app/tests/main_window_tests.cpp 查看文件

@@ -82,6 +82,8 @@ void testModeActionsControlEditingAvailability()
window, "addNormallyOpenAction");
QAction *add_normal_coil_action = requiredChild<QAction>(
window, "addNormalCoilAction");
QAction *parallel_insert_action = requiredChild<QAction>(
window, "parallelInsertAction");
QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
@@ -111,12 +113,19 @@ void testModeActionsControlEditingAvailability()
require(editor_service.findPage(page_id)->controls.empty(),
"deleting a selected control must update the HMI page model");

add_normally_open_action->trigger();
parallel_insert_action->trigger();
add_normally_open_action->trigger();
add_normal_coil_action->trigger();
const ControlLogic *logic = logic_editor_service.findLogic(
logic_editor_service.firstLogicId());
require(logic != nullptr && logic->nodes.size() == 2,
"logic actions must add nodes through the logic editor service");
require(logic != nullptr && logic->rungs.size() == 1,
"logic actions must edit the default ladder rung");
require(logic->rungs.front().stages.size() == 1
&& logic->rungs.front().stages.front().branches.size() == 2,
"parallel insert mode must add a branch to the selected stage");
require(logic->rungs.front().output.has_value(),
"coil action must set the fixed ladder output");

offline_action->trigger();
require(mode_service.mode() == ApplicationMode::OfflineRunning,


+ 31
- 12
app/tests/project_management_tests.cpp 查看文件

@@ -22,7 +22,7 @@ void require(bool condition, const std::string &message)

Project makeExampleProject()
{
// 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本
// 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本
HmiControl start_button;
start_button.id = "start-button";
start_button.type = HmiControlType::Button;
@@ -68,7 +68,6 @@ Project makeExampleProject()
contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};
contact.position = {80, 100};

LogicNode compare;
compare.id = "temperature-check";
@@ -76,22 +75,30 @@ Project makeExampleProject()
RegisterAddress{RegisterArea::D, 2},
ComparisonOperator::GreaterThanOrEqual,
static_cast<std::int16_t>(100)};
compare.position = {320, 100};

LogicNode hold_contact;
hold_contact.id = "hold-contact";
hold_contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyOpen};

LogicNode coil;
coil.id = "run-coil";
coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1},
CoilMode::Set};
coil.position = {560, 100};

ControlLogic logic;
logic.id = "start-logic";
logic.name = "Start logic";
logic.enabled = false;
logic.nodes = {contact, compare, coil};
logic.connections.push_back({contact.id, compare.id});
logic.connections.push_back({compare.id, coil.id});
LadderRung rung;
rung.id = "rung-1";
rung.name = "Network 1";
rung.stages.push_back({"stage-1", {contact, hold_contact}});
rung.stages.push_back({"stage-2", {compare}});
rung.output = coil;
logic.rungs.push_back(rung);

Project project;
project.metadata = {"example-project", "Example project", "1.0"};
@@ -154,6 +161,15 @@ void testExampleProjectRoundTrip()
const QString second_path = directory.filePath("example-copy.json");
require(service.saveAs(first_path.toStdString()).succeeded,
"example project save must succeed");
const QByteArray saved_json = readBytes(first_path);
require(saved_json.contains("\"rungs\"")
&& saved_json.contains("\"stages\"")
&& saved_json.contains("\"branches\"")
&& saved_json.contains("\"output\""),
"saved logic must use the ladder hierarchy");
require(!saved_json.contains("\"position\"")
&& !saved_json.contains("\"connections\""),
"saved ladder logic must not contain free-graph fields");
require(service.load(first_path.toStdString()).succeeded,
"example project load must succeed");

@@ -177,13 +193,16 @@ void testExampleProjectRoundTrip()
"control logic count must survive round trip");
require(!project.controlLogics.front().enabled,
"control logic enabled state must survive round trip");
require(project.controlLogics.front().nodes.size() == 3,
"logic node count must survive round trip");
require(project.controlLogics.front().nodes.front().position.x == 80,
"logic node position must survive round trip");
const LadderRung &rung = project.controlLogics.front().rungs.front();
require(rung.stages.size() == 2,
"ladder stage count must survive round trip");
require(rung.stages.front().branches.size() == 2,
"parallel ladder branches must survive round trip");
require(rung.output.has_value(),
"ladder output must survive round trip");

const auto &compare = std::get<CompareNodeConfig>(
project.controlLogics.front().nodes.at(1).config);
rung.stages.at(1).branches.front().config);
require(compare.address.index() == 2 && compare.value == 100,
"comparison configuration must survive round trip");



+ 7
- 7
docs/ai/handoff.md 查看文件

@@ -86,12 +86,11 @@
- 新增控件先以未绑定状态进入编辑器,避免自动分配地址造成误绑定。
- 离线运行的按钮采用点击切换 M 位语义,数值输入写入一个带符号 16 位 D 字。
- `HmiRuntimeService` 仅通过 `RegisterRepository` 访问寄存器,不依赖虚拟寄存器、串口或 Modbus。
- 控制逻辑使用有向无环图;串联为 AND、多个前驱汇合为 OR、一个输出连接多个后继为并行分支。
- 线圈只能作为终点,保存时要求线圈有输入且每个条件节点最终到达线圈。
- 状态保持通过触点与线圈共享 M 地址表达,不创建图上的反馈环。
- `LogicEditorService` 负责节点和连接编辑,删除节点会同时删除关联连接。
- `LogicEditorWidget` 只保存节点 ID 和连接端点,不访问 HMI、寄存器或 PLC。
- 节点位置保存在 `1.0` 工程文件中,并作为逻辑节点的必填工程字段。
- 控制逻辑使用梯形图层级:逻辑包含网络,网络包含串联条件级,条件级内部包含并联条件,输出线圈固定在右侧。
- 条件级之间执行 AND,同级并联条件执行 OR;状态保持由常闭停止与“常开启动 OR 常开运行反馈”串联后驱动运行线圈。
- `LogicEditorService` 负责网络、串联条件、并联条件、输出线圈、属性和删除操作。
- `LogicEditorWidget` 自动布局母线、支路和节点,不允许自由拖动或手动画线,不保存节点坐标。
- 控制逻辑编辑器不访问 HMI、寄存器仓库或 PLC,工程格式版本保持 `1.0`。
- Validation run and results:
- 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。
- 已启动 `integrated_platform.exe` 并正常退出。
@@ -115,6 +114,7 @@
- 控制逻辑编辑器完成后,领域、逻辑编辑服务、工程管理和主窗口测试全部通过。
- Debug 应用构建成功。
- 启动 Debug 应用确认主窗口创建成功,窗口标题为“综合平台编程器”。
- 梯形图结构重构后,领域、逻辑编辑服务、工程管理和主窗口测试重新构建并全部通过。
- Remaining work: 进入开发顺序第 8 步,实现离线软件逻辑执行器和 HMI 闭环。
- Known risks / blockers: 无。
- Suggested next command: 为控制逻辑建立固定周期的软件执行器并按拓扑顺序求值
- Suggested next command: 为控制逻辑建立固定周期的软件执行器,按网络顺序执行并计算级间 AND 与级内 OR

+ 9
- 9
docs/architecture.md 查看文件

@@ -60,7 +60,7 @@ main.cpp -> UI + Services + Infrastructure
| `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 |
| `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 |
| `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定,并校验控件位于页面边界内。 |
| `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 |
| `control_logic_model.*` | 定义网络、串联级、并联条件和输出线圈,并校验节点类型及地址区域。 |
| `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 |
| `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 |

@@ -84,15 +84,15 @@ main.cpp -> UI + Services + Infrastructure
常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续
添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。

控制逻辑使用有向无环图表达。没有前驱的条件节点从隐式左母线取得真值,条件节点输出为
前驱输入与自身条件的逻辑与;多个前驱在汇合处执行逻辑或,一个输出连接多个后继表示并行
分支。线圈只能作为终点。状态保持通过触点和线圈绑定同一个 M 地址表达,不建立从线圈返回
触点的环形连接。上述语义是后续软件逻辑执行器的确定性求值契约。
控制逻辑按标准梯形图的受限结构表达:一个逻辑包含多个网络,一个网络包含按顺序串联的多个
条件级,每个条件级包含一个或多个并联条件,输出线圈固定在网络最右侧。条件级之间执行逻辑与,
同级并联条件执行逻辑或。状态保持通过启动触点和运行反馈触点并联,并与停止触点串联后驱动运行
线圈表达。该结构是后续软件逻辑执行器的确定性求值契约,不需要通用图遍历或连接表

`services/LogicEditorService` 负责逻辑节点的添加、移动、属性更新、连接、断开和删除。编辑服务
只要求每次操作后的结构合法,允许用户暂时保留尚未连接完成的中间状态;工程保存仍调用完整
领域校验,拒绝无输入线圈和不能到达线圈的条件节点。`ui/LogicEditorWidget` 只投影节点标识和
连接端点,通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。节点画布位置随工程保存。
`services/LogicEditorService` 负责新增和删除网络、追加串联条件、增加并联条件、设置输出线圈、
修改属性和删除节点。空网络可作为编辑占位;非空网络保存时必须同时具备条件和输出线圈。
`ui/LogicEditorWidget` 自动排列左右母线、串联级、并联支路和右侧线圈,不允许自由拖动或手动画线,
也不保存像素坐标。编辑器通过服务提交修改,不访问 HMI、寄存器仓库或 PLC。

## 运行模式边界



+ 2
- 1
docs/开发顺序.md 查看文件

@@ -53,11 +53,12 @@

## 7. 实现控制逻辑编辑器

- 建立图形逻辑模型,支持基本连接、删除和属性配置。
- 建立梯形图逻辑模型,支持网络、串联条件、并联支路、输出线圈、删除和属性配置。
- 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。
- 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。
- 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。
- 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。
- 画布按梯形图结构自动布局,不允许节点自由拖动,也不要求用户手动画线。

完成标准:能够配置一套简单的启动、停止和状态保持逻辑。



Loading…
取消
儲存