feat(core): add network model with typed ports, relations and grouped properties

Data-driven node types (SVG + port config) loaded from JSON, relation-based
connection validation, and a property system supporting string, int, float,
enum, catalogue item(s), color and value-list types grouped by category.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:14:11 +02:00
parent 36dbbebe76
commit 26905a4ba0
14 changed files with 1419 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
build/
*.user
.cache/
compile_commands.json

14
CMakeLists.txt Normal file
View File

@ -0,0 +1,14 @@
cmake_minimum_required(VERSION 3.22)
project(PipeDiagram VERSION 0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Svg Test)
add_subdirectory(src)
enable_testing()
add_subdirectory(tests)

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# PipeDiagram — Pipeline Network Diagram Editor
Qt 6 editor for drawing pipeline networks (water, power, …): SVG nodes with typed
ports, grid-aligned orthogonal connectors with arc jump-overs, grouped editable
properties, styleable edges, and a mock flow simulation with animated flow.
## Build
```bash
cmake -S . -B build -G Ninja
cmake --build build
./build/src/pipediagram
```
## Test
```bash
ctest --test-dir build --output-on-failure
```
See `docs/plan/plan.md` for architecture and the feature checklist.

11
src/CMakeLists.txt Normal file
View File

@ -0,0 +1,11 @@
# Core: document model, routing, solver no Widgets dependency, headless-testable.
add_library(diagcore STATIC
core/Types.h
core/Types.cpp
core/TypeRegistry.h
core/TypeRegistry.cpp
core/NetworkModel.h
core/NetworkModel.cpp
)
target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui)

252
src/core/NetworkModel.cpp Normal file
View File

@ -0,0 +1,252 @@
#include "NetworkModel.h"
namespace diag {
NetworkModel::NetworkModel(TypeRegistry* registry, QObject* parent)
: QObject(parent), m_registry(registry ? registry : &TypeRegistry::instance())
{
}
QUuid NetworkModel::addNode(const QString& typeId, QPointF pos, QUuid id)
{
const NodeType* type = m_registry->nodeType(typeId);
if (!type)
return {};
Node n;
n.id = id.isNull() ? QUuid::createUuid() : id;
n.typeId = typeId;
n.pos = pos;
m_nodes.insert(n.id, n);
m_nodeOrder.append(n.id);
emit nodeAdded(n.id);
return n.id;
}
void NetworkModel::removeNode(QUuid id)
{
if (!m_nodes.contains(id))
return;
const QList<QUuid> attached = edgesOfNode(id);
for (QUuid e : attached)
removeEdge(e);
m_nodes.remove(id);
m_nodeOrder.removeOne(id);
emit nodeRemoved(id);
}
void NetworkModel::moveNode(QUuid id, QPointF pos)
{
auto it = m_nodes.find(id);
if (it == m_nodes.end() || it->pos == pos)
return;
it->pos = pos;
emit nodeMoved(id);
}
const Node* NetworkModel::node(QUuid id) const
{
auto it = m_nodes.constFind(id);
return it == m_nodes.constEnd() ? nullptr : &it.value();
}
QList<QUuid> NetworkModel::nodeIds() const
{
return m_nodeOrder;
}
const NodeType* NetworkModel::nodeTypeOf(QUuid id) const
{
const Node* n = node(id);
return n ? m_registry->nodeType(n->typeId) : nullptr;
}
bool NetworkModel::canConnect(QUuid nodeA, const QString& portA, QUuid nodeB,
const QString& portB, QString* reason) const
{
auto fail = [reason](const QString& why) {
if (reason)
*reason = why;
return false;
};
const NodeType* typeA = nodeTypeOf(nodeA);
const NodeType* typeB = nodeTypeOf(nodeB);
if (!typeA || !typeB)
return fail(tr("Unknown node"));
const PortSpec* pa = typeA->port(portA);
const PortSpec* pb = typeB->port(portB);
if (!pa || !pb)
return fail(tr("Unknown port"));
if (nodeA == nodeB && portA == portB)
return fail(tr("Cannot connect a port to itself"));
if (pa->relation != pb->relation)
return fail(tr("Incompatible port relations (%1 vs %2)").arg(pa->relation, pb->relation));
if (pa->direction == PortDirection::In && pb->direction == PortDirection::In)
return fail(tr("Cannot connect two inputs"));
if (pa->direction == PortDirection::Out && pb->direction == PortDirection::Out)
return fail(tr("Cannot connect two outputs"));
if (pa->maxConnections > 0 && connectionCount(nodeA, portA) >= pa->maxConnections)
return fail(tr("Port %1 is already occupied").arg(portA));
if (pb->maxConnections > 0 && connectionCount(nodeB, portB) >= pb->maxConnections)
return fail(tr("Port %1 is already occupied").arg(portB));
for (const auto& e : m_edges) {
const bool same = (e.fromNode == nodeA && e.fromPort == portA && e.toNode == nodeB
&& e.toPort == portB)
|| (e.fromNode == nodeB && e.fromPort == portB && e.toNode == nodeA
&& e.toPort == portA);
if (same)
return fail(tr("Ports are already connected"));
}
return true;
}
QUuid NetworkModel::addEdge(QUuid fromNode, const QString& fromPort, QUuid toNode,
const QString& toPort, QUuid id)
{
if (!canConnect(fromNode, fromPort, toNode, toPort))
return {};
const NodeType* typeA = nodeTypeOf(fromNode);
const PortSpec* pa = typeA->port(fromPort);
const PortSpec* pb = nodeTypeOf(toNode)->port(toPort);
Edge e;
e.id = id.isNull() ? QUuid::createUuid() : id;
// Normalize direction so the edge always leaves an Out port when one is present.
if (pa->direction == PortDirection::In || pb->direction == PortDirection::Out) {
e.fromNode = toNode;
e.fromPort = toPort;
e.toNode = fromNode;
e.toPort = fromPort;
} else {
e.fromNode = fromNode;
e.fromPort = fromPort;
e.toNode = toNode;
e.toPort = toPort;
}
e.relation = pa->relation;
m_edges.insert(e.id, e);
m_edgeOrder.append(e.id);
emit edgeAdded(e.id);
return e.id;
}
void NetworkModel::removeEdge(QUuid id)
{
if (!m_edges.remove(id))
return;
m_edgeOrder.removeOne(id);
emit edgeRemoved(id);
}
const Edge* NetworkModel::edge(QUuid id) const
{
auto it = m_edges.constFind(id);
return it == m_edges.constEnd() ? nullptr : &it.value();
}
QList<QUuid> NetworkModel::edgeIds() const
{
return m_edgeOrder;
}
QList<QUuid> NetworkModel::edgesOfNode(QUuid nodeId) const
{
QList<QUuid> out;
for (QUuid id : m_edgeOrder) {
const Edge& e = m_edges[id];
if (e.fromNode == nodeId || e.toNode == nodeId)
out.append(id);
}
return out;
}
int NetworkModel::connectionCount(QUuid nodeId, const QString& portId) const
{
int count = 0;
for (const auto& e : m_edges) {
if (e.fromNode == nodeId && e.fromPort == portId)
++count;
if (e.toNode == nodeId && e.toPort == portId)
++count;
}
return count;
}
QVariant NetworkModel::defaultFor(const QList<PropertySpec>& specs, const QString& key)
{
for (const auto& s : specs)
if (s.id == key)
return s.defaultValue;
return {};
}
void NetworkModel::setNodeProperty(QUuid id, const QString& key, const QVariant& value)
{
auto it = m_nodes.find(id);
if (it == m_nodes.end() || it->props.value(key) == value)
return;
it->props.insert(key, value);
emit nodeChanged(id);
}
QVariant NetworkModel::nodeProperty(QUuid id, const QString& key) const
{
const Node* n = node(id);
if (!n)
return {};
if (n->props.contains(key))
return n->props.value(key);
return defaultFor(m_registry->nodeProperties(n->typeId), key);
}
void NetworkModel::setEdgeProperty(QUuid id, const QString& key, const QVariant& value)
{
auto it = m_edges.find(id);
if (it == m_edges.end() || it->props.value(key) == value)
return;
it->props.insert(key, value);
emit edgeChanged(id);
}
QVariant NetworkModel::edgeProperty(QUuid id, const QString& key) const
{
const Edge* e = edge(id);
if (!e)
return {};
if (e->props.contains(key))
return e->props.value(key);
return defaultFor(m_registry->edgeProperties(e->relation), key);
}
void NetworkModel::setEdgeFlow(QUuid id, double rate)
{
auto it = m_edges.find(id);
if (it == m_edges.end() || qFuzzyCompare(it->flowRate + 1.0, rate + 1.0))
return;
it->flowRate = rate;
emit flowChanged(id);
}
QPointF NetworkModel::portScenePos(QUuid nodeId, const QString& portId) const
{
const Node* n = node(nodeId);
const NodeType* t = nodeTypeOf(nodeId);
if (!n || !t)
return {};
const PortSpec* p = t->port(portId);
if (!p)
return n->pos;
return n->pos
+ QPointF(p->pos.x() * t->size.width(), p->pos.y() * t->size.height());
}
void NetworkModel::clear()
{
m_nodes.clear();
m_edges.clear();
m_nodeOrder.clear();
m_edgeOrder.clear();
emit modelReset();
}
} // namespace diag

75
src/core/NetworkModel.h Normal file
View File

@ -0,0 +1,75 @@
#pragma once
#include "TypeRegistry.h"
#include "Types.h"
#include <QHash>
#include <QObject>
namespace diag {
// The document model: nodes placed on the canvas and edges connecting their
// ports. All edits are signalled so views stay in sync. Pure Qt Core/Gui —
// usable headless in tests.
class NetworkModel : public QObject {
Q_OBJECT
public:
explicit NetworkModel(TypeRegistry* registry = nullptr, QObject* parent = nullptr);
TypeRegistry* registry() const { return m_registry; }
// --- nodes ---
QUuid addNode(const QString& typeId, QPointF pos, QUuid id = {});
void removeNode(QUuid id); // cascades to attached edges
void moveNode(QUuid id, QPointF pos);
const Node* node(QUuid id) const;
QList<QUuid> nodeIds() const;
const NodeType* nodeTypeOf(QUuid id) const;
// --- edges ---
bool canConnect(QUuid nodeA, const QString& portA, QUuid nodeB, const QString& portB,
QString* reason = nullptr) const;
QUuid addEdge(QUuid fromNode, const QString& fromPort, QUuid toNode, const QString& toPort,
QUuid id = {});
void removeEdge(QUuid id);
const Edge* edge(QUuid id) const;
QList<QUuid> edgeIds() const;
QList<QUuid> edgesOfNode(QUuid nodeId) const;
int connectionCount(QUuid nodeId, const QString& portId) const;
// --- properties (values fall back to the spec default when unset) ---
void setNodeProperty(QUuid id, const QString& key, const QVariant& value);
QVariant nodeProperty(QUuid id, const QString& key) const;
void setEdgeProperty(QUuid id, const QString& key, const QVariant& value);
QVariant edgeProperty(QUuid id, const QString& key) const;
// Solver results (does not go through the undo stack).
void setEdgeFlow(QUuid id, double rate);
// Scene position of a port (node pos + relative port pos * size).
QPointF portScenePos(QUuid nodeId, const QString& portId) const;
void clear();
signals:
void nodeAdded(QUuid id);
void nodeRemoved(QUuid id);
void nodeMoved(QUuid id);
void nodeChanged(QUuid id);
void edgeAdded(QUuid id);
void edgeRemoved(QUuid id);
void edgeChanged(QUuid id);
void flowChanged(QUuid id);
void modelReset();
private:
static QVariant defaultFor(const QList<PropertySpec>& specs, const QString& key);
TypeRegistry* m_registry;
QHash<QUuid, Node> m_nodes;
QHash<QUuid, Edge> m_edges;
QList<QUuid> m_nodeOrder; // stable iteration/serialization order
QList<QUuid> m_edgeOrder;
};
} // namespace diag

338
src/core/TypeRegistry.cpp Normal file
View File

@ -0,0 +1,338 @@
#include "TypeRegistry.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
namespace diag {
TypeRegistry::TypeRegistry() = default;
TypeRegistry& TypeRegistry::instance()
{
static TypeRegistry reg;
return reg;
}
void TypeRegistry::clear()
{
m_relations.clear();
m_nodeTypes.clear();
m_catalogues.clear();
m_edgeProps.clear();
}
void TypeRegistry::addRelation(const Relation& r)
{
m_relations.append(r);
}
void TypeRegistry::addNodeType(const NodeType& t)
{
m_nodeTypes.append(t);
}
void TypeRegistry::addCatalogue(const Catalogue& c)
{
m_catalogues.append(c);
}
void TypeRegistry::setEdgeProperties(const QString& relationId, const QList<PropertySpec>& specs)
{
m_edgeProps.insert(relationId, specs);
}
const Relation* TypeRegistry::relation(const QString& id) const
{
for (const auto& r : m_relations)
if (r.id == id)
return &r;
return nullptr;
}
QList<Relation> TypeRegistry::relations() const
{
return m_relations;
}
const NodeType* TypeRegistry::nodeType(const QString& id) const
{
for (const auto& t : m_nodeTypes)
if (t.id == id)
return &t;
return nullptr;
}
QList<const NodeType*> TypeRegistry::nodeTypes() const
{
QList<const NodeType*> out;
out.reserve(m_nodeTypes.size());
for (const auto& t : m_nodeTypes)
out.append(&t);
return out;
}
QStringList TypeRegistry::nodeGroups() const
{
QStringList groups;
for (const auto& t : m_nodeTypes)
if (!groups.contains(t.group))
groups.append(t.group);
return groups;
}
const Catalogue* TypeRegistry::catalogue(const QString& id) const
{
for (const auto& c : m_catalogues)
if (c.id == id)
return &c;
return nullptr;
}
QList<const Catalogue*> TypeRegistry::catalogues() const
{
QList<const Catalogue*> out;
out.reserve(m_catalogues.size());
for (const auto& c : m_catalogues)
out.append(&c);
return out;
}
QList<PropertySpec> TypeRegistry::commonNodeProperties()
{
PropertySpec title;
title.id = QStringLiteral("title");
title.name = QStringLiteral("Title");
title.group = QStringLiteral("General");
title.type = PropertyType::String;
title.defaultValue = QString();
PropertySpec labelColor;
labelColor.id = QStringLiteral("labelColor");
labelColor.name = QStringLiteral("Label color");
labelColor.group = QStringLiteral("Presentation");
labelColor.type = PropertyType::Color;
labelColor.defaultValue = QString(); // empty = theme default
PropertySpec tags;
tags.id = QStringLiteral("tags");
tags.name = QStringLiteral("Tags");
tags.group = QStringLiteral("General");
tags.type = PropertyType::ValueList;
tags.defaultValue = QStringList();
return {title, tags, labelColor};
}
QList<PropertySpec> TypeRegistry::commonEdgeProperties()
{
QList<PropertySpec> out;
PropertySpec title;
title.id = QStringLiteral("title");
title.name = QStringLiteral("Title");
title.group = QStringLiteral("General");
title.type = PropertyType::String;
title.defaultValue = QString();
out << title;
PropertySpec lineStyle;
lineStyle.id = QStringLiteral("lineStyle");
lineStyle.name = QStringLiteral("Line style");
lineStyle.group = QStringLiteral("Presentation");
lineStyle.type = PropertyType::Enum;
lineStyle.enumValues = {QStringLiteral("Solid"), QStringLiteral("Dashed"),
QStringLiteral("Dotted"), QStringLiteral("DashDot")};
lineStyle.defaultValue = QStringLiteral("Solid");
out << lineStyle;
PropertySpec width;
width.id = QStringLiteral("lineWidth");
width.name = QStringLiteral("Line width");
width.group = QStringLiteral("Presentation");
width.type = PropertyType::Float;
width.defaultValue = 2.0;
width.min = 0.5;
width.max = 10.0;
out << width;
PropertySpec color;
color.id = QStringLiteral("color");
color.name = QStringLiteral("Color");
color.group = QStringLiteral("Presentation");
color.type = PropertyType::Color;
color.defaultValue = QString(); // empty = relation color
out << color;
const QStringList decorations = {QStringLiteral("None"), QStringLiteral("Arrow"),
QStringLiteral("Circle"), QStringLiteral("Diamond"),
QStringLiteral("Bar")};
PropertySpec head;
head.id = QStringLiteral("headDecoration");
head.name = QStringLiteral("Head decoration");
head.group = QStringLiteral("Presentation");
head.type = PropertyType::Enum;
head.enumValues = decorations;
head.defaultValue = QStringLiteral("Arrow");
out << head;
PropertySpec tail;
tail.id = QStringLiteral("tailDecoration");
tail.name = QStringLiteral("Tail decoration");
tail.group = QStringLiteral("Presentation");
tail.type = PropertyType::Enum;
tail.enumValues = decorations;
tail.defaultValue = QStringLiteral("None");
out << tail;
return out;
}
QList<PropertySpec> TypeRegistry::nodeProperties(const QString& typeId) const
{
QList<PropertySpec> out = commonNodeProperties();
if (const NodeType* t = nodeType(typeId))
out.append(t->properties);
return out;
}
QList<PropertySpec> TypeRegistry::edgeProperties(const QString& relationId) const
{
QList<PropertySpec> out = commonEdgeProperties();
out.append(m_edgeProps.value(relationId));
return out;
}
PropertySpec propertySpecFromJson(const QJsonObject& o)
{
PropertySpec s;
s.id = o.value(QLatin1String("id")).toString();
s.name = o.value(QLatin1String("name")).toString(s.id);
s.group = o.value(QLatin1String("group")).toString(QStringLiteral("General"));
s.type = propertyTypeFromString(o.value(QLatin1String("type")).toString());
s.unit = o.value(QLatin1String("unit")).toString();
s.catalogueId = o.value(QLatin1String("catalogue")).toString();
s.min = o.value(QLatin1String("min")).toDouble();
s.max = o.value(QLatin1String("max")).toDouble();
s.readOnly = o.value(QLatin1String("readOnly")).toBool();
for (const auto& v : o.value(QLatin1String("values")).toArray())
s.enumValues.append(v.toString());
const QJsonValue def = o.value(QLatin1String("default"));
switch (s.type) {
case PropertyType::Int:
s.defaultValue = def.toInt();
break;
case PropertyType::Float:
s.defaultValue = def.toDouble();
break;
case PropertyType::CatalogueItems:
case PropertyType::ValueList: {
QStringList list;
for (const auto& v : def.toArray())
list.append(v.toString());
s.defaultValue = list;
break;
}
default:
s.defaultValue = def.toString();
break;
}
return s;
}
static QPointF pointFromJson(const QJsonValue& v, QPointF fallback = {})
{
const QJsonArray a = v.toArray();
if (a.size() == 2)
return {a.at(0).toDouble(), a.at(1).toDouble()};
return fallback;
}
bool TypeRegistry::loadFromJson(const QByteArray& jsonData, QString* error)
{
QJsonParseError perr;
const QJsonDocument doc = QJsonDocument::fromJson(jsonData, &perr);
if (doc.isNull()) {
if (error)
*error = perr.errorString();
return false;
}
clear();
const QJsonObject root = doc.object();
for (const auto& rv : root.value(QLatin1String("relations")).toArray()) {
const QJsonObject o = rv.toObject();
Relation r;
r.id = o.value(QLatin1String("id")).toString();
r.name = o.value(QLatin1String("name")).toString(r.id);
r.color = QColor(o.value(QLatin1String("color")).toString());
r.darkColor = QColor(o.value(QLatin1String("darkColor")).toString(
o.value(QLatin1String("color")).toString()));
addRelation(r);
}
for (const auto& cv : root.value(QLatin1String("catalogues")).toArray()) {
const QJsonObject o = cv.toObject();
Catalogue c;
c.id = o.value(QLatin1String("id")).toString();
c.name = o.value(QLatin1String("name")).toString(c.id);
for (const auto& iv : o.value(QLatin1String("items")).toArray()) {
const QJsonObject io = iv.toObject();
CatalogueItem item;
item.id = io.value(QLatin1String("id")).toString();
item.name = io.value(QLatin1String("name")).toString(item.id);
item.attributes = io.value(QLatin1String("attributes")).toObject().toVariantMap();
c.items.append(item);
}
addCatalogue(c);
}
const QJsonObject edgeProps = root.value(QLatin1String("edgeProperties")).toObject();
for (auto it = edgeProps.begin(); it != edgeProps.end(); ++it) {
QList<PropertySpec> specs;
for (const auto& pv : it.value().toArray())
specs.append(propertySpecFromJson(pv.toObject()));
setEdgeProperties(it.key(), specs);
}
for (const auto& tv : root.value(QLatin1String("nodeTypes")).toArray()) {
const QJsonObject o = tv.toObject();
NodeType t;
t.id = o.value(QLatin1String("id")).toString();
t.name = o.value(QLatin1String("name")).toString(t.id);
t.group = o.value(QLatin1String("group")).toString(QStringLiteral("Misc"));
t.svgPath = o.value(QLatin1String("svg")).toString();
const QPointF sz = pointFromJson(o.value(QLatin1String("size")), {60, 60});
t.size = QSizeF(sz.x(), sz.y());
for (const auto& pv : o.value(QLatin1String("ports")).toArray()) {
const QJsonObject po = pv.toObject();
PortSpec p;
p.id = po.value(QLatin1String("id")).toString();
p.relation = po.value(QLatin1String("relation")).toString();
p.direction = portDirectionFromString(po.value(QLatin1String("direction")).toString());
p.pos = pointFromJson(po.value(QLatin1String("pos")), {0.5, 0.5});
p.side = sideFromString(po.value(QLatin1String("side")).toString());
p.maxConnections = po.value(QLatin1String("maxConnections")).toInt(1);
t.ports.append(p);
}
for (const auto& pv : o.value(QLatin1String("properties")).toArray())
t.properties.append(propertySpecFromJson(pv.toObject()));
addNodeType(t);
}
return true;
}
bool TypeRegistry::loadFromFile(const QString& path, QString* error)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) {
if (error)
*error = f.errorString();
return false;
}
return loadFromJson(f.readAll(), error);
}
} // namespace diag

55
src/core/TypeRegistry.h Normal file
View File

@ -0,0 +1,55 @@
#pragma once
#include "Types.h"
#include <QHash>
#include <QJsonObject>
namespace diag {
// Holds relation, node-type, catalogue and edge-property definitions.
// Definitions are data-driven: loaded from a JSON config (see
// resources/config/nodetypes.json). Common presentation/general property
// specs for nodes and edges are built in.
class TypeRegistry {
public:
TypeRegistry();
static TypeRegistry& instance();
bool loadFromJson(const QByteArray& jsonData, QString* error = nullptr);
bool loadFromFile(const QString& path, QString* error = nullptr);
void clear();
void addRelation(const Relation& r);
void addNodeType(const NodeType& t);
void addCatalogue(const Catalogue& c);
void setEdgeProperties(const QString& relationId, const QList<PropertySpec>& specs);
const Relation* relation(const QString& id) const;
QList<Relation> relations() const;
const NodeType* nodeType(const QString& id) const;
QList<const NodeType*> nodeTypes() const;
QStringList nodeGroups() const; // in definition order
const Catalogue* catalogue(const QString& id) const;
QList<const Catalogue*> catalogues() const;
// Full spec list: common specs + type/relation specific ones.
QList<PropertySpec> nodeProperties(const QString& typeId) const;
QList<PropertySpec> edgeProperties(const QString& relationId) const;
static QList<PropertySpec> commonNodeProperties();
static QList<PropertySpec> commonEdgeProperties();
private:
QList<Relation> m_relations;
QList<NodeType> m_nodeTypes;
QList<Catalogue> m_catalogues;
QHash<QString, QList<PropertySpec>> m_edgeProps; // by relation id
};
PropertySpec propertySpecFromJson(const QJsonObject& o);
} // namespace diag

107
src/core/Types.cpp Normal file
View File

@ -0,0 +1,107 @@
#include "Types.h"
namespace diag {
const PortSpec* NodeType::port(const QString& portId) const
{
for (const auto& p : ports)
if (p.id == portId)
return &p;
return nullptr;
}
const CatalogueItem* Catalogue::item(const QString& itemId) const
{
for (const auto& i : items)
if (i.id == itemId)
return &i;
return nullptr;
}
QPointF sideVector(Side s)
{
switch (s) {
case Side::Left: return {-1, 0};
case Side::Right: return {1, 0};
case Side::Top: return {0, -1};
case Side::Bottom: return {0, 1};
}
return {1, 0};
}
QString portDirectionToString(PortDirection d)
{
switch (d) {
case PortDirection::In: return QStringLiteral("in");
case PortDirection::Out: return QStringLiteral("out");
case PortDirection::InOut: return QStringLiteral("inout");
}
return QStringLiteral("inout");
}
PortDirection portDirectionFromString(const QString& s)
{
if (s == QLatin1String("in"))
return PortDirection::In;
if (s == QLatin1String("out"))
return PortDirection::Out;
return PortDirection::InOut;
}
QString sideToString(Side s)
{
switch (s) {
case Side::Left: return QStringLiteral("left");
case Side::Right: return QStringLiteral("right");
case Side::Top: return QStringLiteral("top");
case Side::Bottom: return QStringLiteral("bottom");
}
return QStringLiteral("left");
}
Side sideFromString(const QString& s)
{
if (s == QLatin1String("right"))
return Side::Right;
if (s == QLatin1String("top"))
return Side::Top;
if (s == QLatin1String("bottom"))
return Side::Bottom;
return Side::Left;
}
QString propertyTypeToString(PropertyType t)
{
switch (t) {
case PropertyType::String: return QStringLiteral("string");
case PropertyType::Int: return QStringLiteral("int");
case PropertyType::Float: return QStringLiteral("float");
case PropertyType::Enum: return QStringLiteral("enum");
case PropertyType::CatalogueItem: return QStringLiteral("catalogueItem");
case PropertyType::CatalogueItems: return QStringLiteral("catalogueItems");
case PropertyType::Color: return QStringLiteral("color");
case PropertyType::ValueList: return QStringLiteral("valueList");
}
return QStringLiteral("string");
}
PropertyType propertyTypeFromString(const QString& s)
{
if (s == QLatin1String("int"))
return PropertyType::Int;
if (s == QLatin1String("float"))
return PropertyType::Float;
if (s == QLatin1String("enum"))
return PropertyType::Enum;
if (s == QLatin1String("catalogueItem"))
return PropertyType::CatalogueItem;
if (s == QLatin1String("catalogueItems"))
return PropertyType::CatalogueItems;
if (s == QLatin1String("color"))
return PropertyType::Color;
if (s == QLatin1String("valueList"))
return PropertyType::ValueList;
return PropertyType::String;
}
} // namespace diag

118
src/core/Types.h Normal file
View File

@ -0,0 +1,118 @@
#pragma once
#include <QColor>
#include <QList>
#include <QPointF>
#include <QSizeF>
#include <QString>
#include <QStringList>
#include <QUuid>
#include <QVariant>
#include <QVariantMap>
namespace diag {
// Which side of the node rectangle a port sits on; defines the exit
// direction of the first routed segment.
enum class Side { Left, Right, Top, Bottom };
enum class PortDirection { In, Out, InOut };
// A connection domain (water, power, ...). Edges may only join ports of the
// same relation. Colors are per color scheme.
struct Relation {
QString id;
QString name;
QColor color; // light scheme
QColor darkColor; // dark / high-contrast schemes
};
struct PortSpec {
QString id;
QString relation;
PortDirection direction = PortDirection::InOut;
QPointF pos; // relative [0..1] within the node rect
Side side = Side::Left;
int maxConnections = 1; // 0 = unlimited
};
enum class PropertyType {
String,
Int,
Float,
Enum,
CatalogueItem, // value: item id (QString)
CatalogueItems, // value: item ids (QStringList)
Color, // value: "#rrggbb" string; empty = automatic
ValueList, // value: QStringList of free-form values
};
struct PropertySpec {
QString id;
QString name;
QString group; // "General", "Presentation", "Physics", ...
PropertyType type = PropertyType::String;
QVariant defaultValue;
QString unit;
QStringList enumValues;
QString catalogueId;
double min = 0.0;
double max = 0.0; // min == max means unbounded
bool readOnly = false;
};
struct NodeType {
QString id;
QString name;
QString group; // palette group
QString svgPath; // resource path of the vector image
QSizeF size{60, 60};
QList<PortSpec> ports;
QList<PropertySpec> properties; // in addition to common node properties
const PortSpec* port(const QString& portId) const;
};
struct CatalogueItem {
QString id;
QString name;
QVariantMap attributes; // e.g. {"diameter": 50, "material": "steel"}
};
struct Catalogue {
QString id;
QString name;
QList<CatalogueItem> items;
const CatalogueItem* item(const QString& itemId) const;
};
struct Node {
QUuid id;
QString typeId;
QPointF pos; // scene coordinates of the node rect's top-left corner
QVariantMap props;
};
struct Edge {
QUuid id;
QUuid fromNode;
QString fromPort;
QUuid toNode;
QString toPort;
QString relation;
QVariantMap props;
double flowRate = 0.0; // solver result; positive flows from -> to
};
// Unit vector pointing away from the node for a given side.
QPointF sideVector(Side s);
QString portDirectionToString(PortDirection d);
PortDirection portDirectionFromString(const QString& s);
QString sideToString(Side s);
Side sideFromString(const QString& s);
QString propertyTypeToString(PropertyType t);
PropertyType propertyTypeFromString(const QString& s);
} // namespace diag

10
tests/CMakeLists.txt Normal file
View File

@ -0,0 +1,10 @@
# Test targets are added per module; all run offscreen via ctest.
function(diag_add_test NAME)
add_executable(${NAME} ${NAME}.cpp)
target_link_libraries(${NAME} PRIVATE ${ARGN} Qt6::Test)
add_test(NAME ${NAME} COMMAND ${NAME})
set_tests_properties(${NAME} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endfunction()
diag_add_test(tst_model diagcore)
diag_add_test(tst_properties diagcore)

135
tests/TestFixtures.h Normal file
View File

@ -0,0 +1,135 @@
#pragma once
// Shared in-code registry fixture: a minimal water + power schema mirroring
// the shape of the shipped JSON config, without resource dependencies.
#include "core/TypeRegistry.h"
namespace fixtures {
inline diag::PropertySpec floatSpec(const QString& id, const QString& name, const QString& group,
double def, const QString& unit = {})
{
diag::PropertySpec s;
s.id = id;
s.name = name;
s.group = group;
s.type = diag::PropertyType::Float;
s.defaultValue = def;
s.unit = unit;
return s;
}
inline diag::PortSpec port(const QString& id, const QString& relation, diag::PortDirection dir,
QPointF pos, diag::Side side, int maxConnections = 1)
{
diag::PortSpec p;
p.id = id;
p.relation = relation;
p.direction = dir;
p.pos = pos;
p.side = side;
p.maxConnections = maxConnections;
return p;
}
inline void fillRegistry(diag::TypeRegistry& reg)
{
using namespace diag;
reg.clear();
reg.addRelation({QStringLiteral("water"), QStringLiteral("Water"), QColor("#1c78c0"),
QColor("#4aa3e8")});
reg.addRelation({QStringLiteral("power"), QStringLiteral("Power"), QColor("#d99000"),
QColor("#f0b429")});
Catalogue pipes;
pipes.id = QStringLiteral("pipe_series");
pipes.name = QStringLiteral("Pipe series");
pipes.items = {{QStringLiteral("dn50"), QStringLiteral("DN50 Steel"),
{{QStringLiteral("diameter"), 50}}},
{QStringLiteral("dn100"), QStringLiteral("DN100 Steel"),
{{QStringLiteral("diameter"), 100}}}};
reg.addCatalogue(pipes);
QList<PropertySpec> waterEdge;
waterEdge << floatSpec(QStringLiteral("length"), QStringLiteral("Length"),
QStringLiteral("Physics"), 10.0, QStringLiteral("m"));
waterEdge << floatSpec(QStringLiteral("diameter"), QStringLiteral("Diameter"),
QStringLiteral("Physics"), 100.0, QStringLiteral("mm"));
PropertySpec flowType;
flowType.id = QStringLiteral("flowType");
flowType.name = QStringLiteral("Flow type");
flowType.group = QStringLiteral("Physics");
flowType.type = PropertyType::Enum;
flowType.enumValues = {QStringLiteral("Laminar"), QStringLiteral("Turbulent")};
flowType.defaultValue = QStringLiteral("Laminar");
waterEdge << flowType;
PropertySpec series;
series.id = QStringLiteral("series");
series.name = QStringLiteral("Pipe series");
series.group = QStringLiteral("Physics");
series.type = PropertyType::CatalogueItem;
series.catalogueId = QStringLiteral("pipe_series");
waterEdge << series;
reg.setEdgeProperties(QStringLiteral("water"), waterEdge);
NodeType source;
source.id = QStringLiteral("source");
source.name = QStringLiteral("Source");
source.group = QStringLiteral("Water");
source.size = {60, 60};
source.ports = {port(QStringLiteral("out"), QStringLiteral("water"), PortDirection::Out,
{1.0, 0.5}, Side::Right, 0)};
source.properties = {floatSpec(QStringLiteral("supply"), QStringLiteral("Supply"),
QStringLiteral("Physics"), 10.0, QStringLiteral("m³/h"))};
reg.addNodeType(source);
NodeType pump;
pump.id = QStringLiteral("pump");
pump.name = QStringLiteral("Pump");
pump.group = QStringLiteral("Water");
pump.size = {60, 60};
pump.ports = {port(QStringLiteral("in"), QStringLiteral("water"), PortDirection::In,
{0.0, 0.5}, Side::Left),
port(QStringLiteral("out"), QStringLiteral("water"), PortDirection::Out,
{1.0, 0.5}, Side::Right)};
reg.addNodeType(pump);
NodeType junction;
junction.id = QStringLiteral("junction");
junction.name = QStringLiteral("Junction");
junction.group = QStringLiteral("Water");
junction.size = {40, 40};
junction.ports = {port(QStringLiteral("w"), QStringLiteral("water"), PortDirection::InOut,
{0.0, 0.5}, Side::Left, 0),
port(QStringLiteral("e"), QStringLiteral("water"), PortDirection::InOut,
{1.0, 0.5}, Side::Right, 0),
port(QStringLiteral("n"), QStringLiteral("water"), PortDirection::InOut,
{0.5, 0.0}, Side::Top, 0),
port(QStringLiteral("s"), QStringLiteral("water"), PortDirection::InOut,
{0.5, 1.0}, Side::Bottom, 0)};
reg.addNodeType(junction);
NodeType consumer;
consumer.id = QStringLiteral("consumer");
consumer.name = QStringLiteral("Consumer");
consumer.group = QStringLiteral("Water");
consumer.size = {60, 60};
consumer.ports = {port(QStringLiteral("in"), QStringLiteral("water"), PortDirection::In,
{0.0, 0.5}, Side::Left)};
consumer.properties = {floatSpec(QStringLiteral("demand"), QStringLiteral("Demand"),
QStringLiteral("Physics"), 10.0, QStringLiteral("m³/h"))};
reg.addNodeType(consumer);
NodeType generator;
generator.id = QStringLiteral("generator");
generator.name = QStringLiteral("Generator");
generator.group = QStringLiteral("Power");
generator.size = {60, 60};
generator.ports = {port(QStringLiteral("out"), QStringLiteral("power"), PortDirection::Out,
{1.0, 0.5}, Side::Right, 0)};
reg.addNodeType(generator);
}
} // namespace fixtures

145
tests/tst_model.cpp Normal file
View File

@ -0,0 +1,145 @@
#include "TestFixtures.h"
#include "core/NetworkModel.h"
#include <QSignalSpy>
#include <QTest>
using namespace diag;
class TestModel : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_model;
m_model = new NetworkModel(&m_reg, this);
}
void addNode_unknownTypeFails()
{
QVERIFY(m_model->addNode(QStringLiteral("nope"), {0, 0}).isNull());
QCOMPARE(m_model->nodeIds().size(), 0);
}
void addNode_emitsAndStores()
{
QSignalSpy spy(m_model, &NetworkModel::nodeAdded);
const QUuid id = m_model->addNode(QStringLiteral("pump"), {100, 40});
QVERIFY(!id.isNull());
QCOMPARE(spy.count(), 1);
QCOMPARE(m_model->node(id)->pos, QPointF(100, 40));
QCOMPARE(m_model->nodeTypeOf(id)->id, QStringLiteral("pump"));
}
void connect_validOutToIn()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
QVERIFY(m_model->canConnect(src, "out", pump, "in"));
const QUuid e = m_model->addEdge(src, "out", pump, "in");
QVERIFY(!e.isNull());
QCOMPARE(m_model->edge(e)->relation, QStringLiteral("water"));
}
void connect_directionNormalized()
{
// Dragging from the In port must still produce an Out->In edge.
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
const QUuid e = m_model->addEdge(pump, "in", src, "out");
QVERIFY(!e.isNull());
QCOMPARE(m_model->edge(e)->fromNode, src);
QCOMPARE(m_model->edge(e)->toNode, pump);
}
void connect_relationMismatchFails()
{
const QUuid gen = m_model->addNode(QStringLiteral("generator"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
QString reason;
QVERIFY(!m_model->canConnect(gen, "out", pump, "in", &reason));
QVERIFY(reason.contains(QStringLiteral("relation")));
QVERIFY(m_model->addEdge(gen, "out", pump, "in").isNull());
}
void connect_twoInputsFails()
{
const QUuid a = m_model->addNode(QStringLiteral("pump"), {0, 0});
const QUuid b = m_model->addNode(QStringLiteral("consumer"), {200, 0});
QVERIFY(!m_model->canConnect(a, "in", b, "in"));
}
void connect_selfPortFails()
{
const QUuid j = m_model->addNode(QStringLiteral("junction"), {0, 0});
QVERIFY(!m_model->canConnect(j, "w", j, "w"));
// Different ports on the same node are allowed.
QVERIFY(m_model->canConnect(j, "w", j, "e"));
}
void connect_occupiedPortFails()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid p1 = m_model->addNode(QStringLiteral("pump"), {200, 0});
const QUuid p2 = m_model->addNode(QStringLiteral("pump"), {200, 100});
QVERIFY(!m_model->addEdge(src, "out", p1, "in").isNull());
QString reason;
QVERIFY(!m_model->canConnect(p2, "out", p1, "in", &reason));
QVERIFY(reason.contains(QStringLiteral("occupied")));
// Source "out" is unlimited (maxConnections = 0).
QVERIFY(m_model->canConnect(src, "out", p2, "in"));
}
void connect_duplicateEdgeFails()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid j = m_model->addNode(QStringLiteral("junction"), {200, 0});
QVERIFY(!m_model->addEdge(src, "out", j, "w").isNull());
QVERIFY(!m_model->canConnect(src, "out", j, "w"));
QVERIFY(!m_model->canConnect(j, "w", src, "out"));
}
void junction_allowsManyConnections()
{
const QUuid j = m_model->addNode(QStringLiteral("junction"), {0, 0});
const QUuid src = m_model->addNode(QStringLiteral("source"), {-200, 0});
const QUuid c1 = m_model->addNode(QStringLiteral("consumer"), {200, -100});
const QUuid c2 = m_model->addNode(QStringLiteral("consumer"), {200, 100});
QVERIFY(!m_model->addEdge(src, "out", j, "w").isNull());
QVERIFY(!m_model->addEdge(j, "e", c1, "in").isNull());
QVERIFY(!m_model->addEdge(j, "e", c2, "in").isNull());
QCOMPARE(m_model->connectionCount(j, "e"), 2);
}
void removeNode_cascadesEdges()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {400, 0});
m_model->addEdge(src, "out", pump, "in");
m_model->addEdge(pump, "out", c, "in");
QSignalSpy edgeSpy(m_model, &NetworkModel::edgeRemoved);
m_model->removeNode(pump);
QCOMPARE(edgeSpy.count(), 2);
QCOMPARE(m_model->edgeIds().size(), 0);
QCOMPARE(m_model->nodeIds().size(), 2);
}
void moveNode_emitsAndUpdatesPortPos()
{
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {0, 0});
QSignalSpy spy(m_model, &NetworkModel::nodeMoved);
m_model->moveNode(pump, {100, 100});
QCOMPARE(spy.count(), 1);
// Port "out" is at relative (1.0, 0.5) of a 60x60 node.
QCOMPARE(m_model->portScenePos(pump, "out"), QPointF(160, 130));
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
};
QTEST_GUILESS_MAIN(TestModel)
#include "tst_model.moc"

134
tests/tst_properties.cpp Normal file
View File

@ -0,0 +1,134 @@
#include "TestFixtures.h"
#include "core/NetworkModel.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTest>
using namespace diag;
class TestProperties : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_model;
m_model = new NetworkModel(&m_reg, this);
}
void nodeProperty_fallsBackToDefault()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 10.0);
QCOMPARE(m_model->nodeProperty(src, "title").toString(), QString());
m_model->setNodeProperty(src, "supply", 42.5);
QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 42.5);
}
void edgeProperty_commonAndRelationSpecs()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
const QUuid e = m_model->addEdge(src, "out", pump, "in");
const auto specs = m_reg.edgeProperties(QStringLiteral("water"));
QStringList ids;
for (const auto& s : specs)
ids << s.id;
// Common presentation specs plus water physics specs.
for (const char* expected : {"title", "lineStyle", "lineWidth", "color",
"headDecoration", "tailDecoration", "length", "diameter",
"flowType", "series"})
QVERIFY2(ids.contains(QLatin1String(expected)), expected);
QCOMPARE(m_model->edgeProperty(e, "lineStyle").toString(), QStringLiteral("Solid"));
QCOMPARE(m_model->edgeProperty(e, "diameter").toDouble(), 100.0);
QCOMPARE(m_model->edgeProperty(e, "flowType").toString(), QStringLiteral("Laminar"));
}
void propertyGroups_present()
{
const auto specs = m_reg.edgeProperties(QStringLiteral("water"));
QStringList groups;
for (const auto& s : specs)
if (!groups.contains(s.group))
groups << s.group;
QVERIFY(groups.contains(QStringLiteral("General")));
QVERIFY(groups.contains(QStringLiteral("Presentation")));
QVERIFY(groups.contains(QStringLiteral("Physics")));
}
void allPropertyTypes_storeAndRead()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
const QUuid e = m_model->addEdge(src, "out", pump, "in");
m_model->setNodeProperty(src, "title", QStringLiteral("Main intake")); // String
m_model->setNodeProperty(src, "tags", QStringList{"district-1", "hp"}); // ValueList
m_model->setNodeProperty(src, "labelColor", QStringLiteral("#ff8800")); // Color
m_model->setEdgeProperty(e, "length", 125.5); // Float
m_model->setEdgeProperty(e, "flowType", QStringLiteral("Turbulent")); // Enum
m_model->setEdgeProperty(e, "series", QStringLiteral("dn50")); // CatalogueItem
QCOMPARE(m_model->nodeProperty(src, "title").toString(), QStringLiteral("Main intake"));
QCOMPARE(m_model->nodeProperty(src, "tags").toStringList(),
(QStringList{"district-1", "hp"}));
QCOMPARE(m_model->nodeProperty(src, "labelColor").toString(), QStringLiteral("#ff8800"));
QCOMPARE(m_model->edgeProperty(e, "length").toDouble(), 125.5);
QCOMPARE(m_model->edgeProperty(e, "flowType").toString(), QStringLiteral("Turbulent"));
QCOMPARE(m_model->edgeProperty(e, "series").toString(), QStringLiteral("dn50"));
}
void catalogue_lookup()
{
const Catalogue* c = m_reg.catalogue(QStringLiteral("pipe_series"));
QVERIFY(c);
QCOMPARE(c->items.size(), 2);
const CatalogueItem* dn50 = c->item(QStringLiteral("dn50"));
QVERIFY(dn50);
QCOMPARE(dn50->attributes.value(QStringLiteral("diameter")).toInt(), 50);
QVERIFY(!c->item(QStringLiteral("missing")));
}
void propertySpec_parsesFromJson()
{
const auto doc = QJsonDocument::fromJson(R"({
"id": "materials", "name": "Materials", "group": "Physics",
"type": "catalogueItems", "catalogue": "pipe_series",
"default": ["dn50"], "readOnly": false
})");
const PropertySpec s = propertySpecFromJson(doc.object());
QCOMPARE(s.type, PropertyType::CatalogueItems);
QCOMPARE(s.catalogueId, QStringLiteral("pipe_series"));
QCOMPARE(s.defaultValue.toStringList(), QStringList{"dn50"});
const auto doc2 = QJsonDocument::fromJson(R"({
"id": "mode", "type": "enum", "values": ["A", "B"], "default": "B",
"min": 1, "max": 5
})");
const PropertySpec s2 = propertySpecFromJson(doc2.object());
QCOMPARE(s2.enumValues, (QStringList{"A", "B"}));
QCOMPARE(s2.defaultValue.toString(), QStringLiteral("B"));
QCOMPARE(s2.min, 1.0);
QCOMPARE(s2.max, 5.0);
}
void propertyChange_emitsSignal()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
QSignalSpy spy(m_model, &NetworkModel::nodeChanged);
m_model->setNodeProperty(src, "title", QStringLiteral("A"));
m_model->setNodeProperty(src, "title", QStringLiteral("A")); // no-op
QCOMPARE(spy.count(), 1);
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
};
QTEST_GUILESS_MAIN(TestProperties)
#include "tst_properties.moc"