Compare commits

...

10 Commits

Author SHA1 Message Date
Ilya Ashikhmin
6990bff809 docs: complete plan checklist, add screenshots and feature overview
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:39:31 +02:00
Ilya Ashikhmin
ef14fed305 feat(app): persist window geometry and dock layout across sessions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:39:31 +02:00
Ilya Ashikhmin
3129440e60 feat(scene): add canvas and element context menus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:39:31 +02:00
Ilya Ashikhmin
730d0fa7a7 feat(app): add --simulate flag for headless demo captures
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:38:07 +02:00
Ilya Ashikhmin
4f6fe37b4d test(app): add end-to-end scenarios against the real main window
Bundled registry/sample loading, edge selection populating the grouped
property panel, undoable title edit, solver + animation clock, and a
headless render smoke check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:37:18 +02:00
Ilya Ashikhmin
18289b33e1 feat(app): add main window with palette, property panel and settings
- Grouped node palette with SVG icons, drag-and-drop onto the canvas
- Property panel: grouped typed editors (string, int, float, enum,
  catalogue single/multi pickers, color with auto-reset, value list),
  read-only solver results, undo-aware edits
- Settings dialog persisted via QSettings: grid step, snapping,
  intersection arcs, color scheme
- File new/open/save with dirty-check, bundled sample document, zoom and
  simulation controls, status bar with cursor position and zoom
- --screenshot flag for headless smoke checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:13 +02:00
Ilya Ashikhmin
a52f7bffbf feat(assets): add SVG node set, node-type config and sample document
Water supply group (source, tank, pump, valve, junction, consumer) and
power group (generator, transformer, switch, load) with port configs,
physics property specs, pipe/cable/insulation catalogues and a demo
waterworks document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:27:05 +02:00
Ilya Ashikhmin
b257c1a7cf feat(scene): add interactive graphics scene with grid, ports and styled edges
- SVG node items with snap-to-grid dragging; ports appear on focus/hover
  and highlight compatible targets during a connection drag
- Orthogonally routed edge items with line styles, head/tail decorations
  (arrow, circle, diamond, bar), selection halo and title labels; later
  edges jump crossings with arcs when enabled
- Grid background view with cursor-anchored zoom, middle-button pan,
  rubber-band selection and palette drag-and-drop
- Undoable commands: add/move/delete/connect/property/paste with
  clipboard fragments; light/dark/high-contrast themes
- Flow overlay: animated dashes, direction and density follow flow rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:25:06 +02:00
Ilya Ashikhmin
81f2a9ff8b feat(core): add JSON document save/load
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:14:11 +02:00
Ilya Ashikhmin
c2501ff04e feat(core): add mock flow solver with conservation-based distribution
Treats each relation network as a conductance network (diameter^4 / length),
solves nodal balance per connected component, so flow is conserved at every
junction and wider pipes carry proportionally more flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:14:11 +02:00
50 changed files with 4293 additions and 51 deletions

View File

@ -4,18 +4,49 @@ Qt 6 editor for drawing pipeline networks (water, power, …): SVG nodes with ty
ports, grid-aligned orthogonal connectors with arc jump-overs, grouped editable ports, grid-aligned orthogonal connectors with arc jump-overs, grouped editable
properties, styleable edges, and a mock flow simulation with animated flow. properties, styleable edges, and a mock flow simulation with animated flow.
## Build ![Editor](docs/images/editor.png)
## Features
- **Node palette** — grouped vector node types (water supply, power) dragged onto the canvas
- **Typed ports with relations** — ports appear when a node is focused/hovered; during a
connection drag only relation-compatible ports highlight (water ↔ water, power ↔ power,
in/out direction and occupancy checked)
- **Grid routing** — edges run orthogonally along the rectangular grid; a configurable
option draws semicircular arcs where edges cross
- **Grouped properties** — right panel edits the selected node/edge: General, Presentation
and Physics groups with string, int, float, enum, catalogue item, catalogue items,
color and value-list editors; physics props include length, diameter, flow rate, flow type
- **Edge styles** — solid/dashed/dotted/dash-dot lines, width, color override, head/tail
decorations: arrow, circle, diamond, bar
- **Editing UX** — snap-to-grid dragging, multi-select, rubber band, copy/paste/cut,
delete with cascade, full undo/redo, Ctrl+wheel zoom, middle-button pan, zoom-to-fit,
JSON save/load, color schemes (light/dark/high contrast)
- **Mock flow simulation** — conservation-based flow distribution (wider/shorter pipes
carry more); animated dashes show direction, speed and thickness follow flow volume
![Simulation](docs/images/simulation.png)
## Build & run
```bash ```bash
cmake -S . -B build -G Ninja cmake -S . -B build -G Ninja
cmake --build build cmake --build build
./build/src/pipediagram ./build/src/pipediagram # empty document
./build/src/pipediagram --sample # demo waterworks network
``` ```
Requires Qt 6 (base + svg), CMake ≥ 3.22. `--screenshot out.png` renders headlessly
(with `QT_QPA_PLATFORM=offscreen`), `--simulate` starts the flow simulation.
## Test ## Test
```bash ```bash
ctest --test-dir build --output-on-failure ctest --test-dir build --output-on-failure
``` ```
See `docs/plan/plan.md` for architecture and the feature checklist. Eight offscreen suites cover the model and connection rules, property system and
catalogues, orthogonal routing and intersection arcs, the flow solver, JSON round-trips,
undo commands, interactive scene scenarios and end-to-end main-window flows.
See `docs/plan/plan.md` for architecture and the tool-selection rationale.

BIN
docs/images/editor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
docs/images/simulation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@ -54,71 +54,71 @@ through QUndoCommands.
## Checklist ## Checklist
### 1. Foundation ### 1. Foundation
- [ ] Install Qt 6 (base, svg), ninja; verify cmake configure - [x] Install Qt 6 (base, svg), ninja; verify cmake configure
- [ ] Project scaffold: CMakeLists (app + tests), .gitignore, README - [x] Project scaffold: CMakeLists (app + tests), .gitignore, README
- [ ] This plan committed under docs/plan - [x] This plan committed under docs/plan
### 2. Core model ### 2. Core model
- [ ] Relations (water, gas, power, control, …) with per-scheme colors - [x] Relations (water, gas, power, control, …) with per-scheme colors
- [ ] PortSpec: id, relation, direction (in/out/bidirectional), relative position on node - [x] PortSpec: id, relation, direction (in/out/bidirectional), relative position on node
- [ ] NodeType: id, group, label, SVG path, port list, default size, property specs - [x] NodeType: id, group, label, SVG path, port list, default size, property specs
- [ ] Node/Edge/NetworkModel: add/remove/move, connect with validation (relation match, direction, no self-port duplicates, occupancy) - [x] Node/Edge/NetworkModel: add/remove/move, connect with validation (relation match, direction, no self-port duplicates, occupancy)
- [ ] Signals for model→view sync - [x] Signals for model→view sync
### 3. Property system ### 3. Property system
- [ ] Property types: String, Int, Float, Enum, CatalogueItem, CatalogueItems, Color, ValueList - [x] Property types: String, Int, Float, Enum, CatalogueItem, CatalogueItems, Color, ValueList
- [ ] PropertySpec with group ("General", "Presentation", "Physics"), unit, limits, read-only flag - [x] PropertySpec with group ("General", "Presentation", "Physics"), unit, limits, read-only flag
- [ ] Catalogue store (e.g. pipe series DN/material) loaded from JSON - [x] Catalogue store (e.g. pipe series DN/material) loaded from JSON
- [ ] Default specs: node (title, color, rotation…), edge (title, length, diameter, flow rate, flow type, style props) - [x] Default specs: node (title, color, rotation…), edge (title, length, diameter, flow rate, flow type, style props)
### 4. Routing ### 4. Routing
- [ ] Orthogonal (Manhattan) routing snapped to grid, port-direction-aware first segment - [x] Orthogonal (Manhattan) routing snapped to grid, port-direction-aware first segment
- [ ] Config: grid step, `arcOnIntersection` — arc jump-over where an edge crosses another - [x] Config: grid step, `arcOnIntersection` — arc jump-over where an edge crosses another
- [ ] Intersection computation between routed polylines - [x] Intersection computation between routed polylines
- [ ] Painter-path builder producing arcs over crossings - [x] Painter-path builder producing arcs over crossings
### 5. Scene / items ### 5. Scene / items
- [ ] Grid background + snap-to-grid - [x] Grid background + snap-to-grid
- [ ] NodeItem: shared QSvgRenderer per type, selection highlight, drag with snapping - [x] NodeItem: shared QSvgRenderer per type, selection highlight, drag with snapping
- [ ] PortItem: visible on node focus/hover, colored by relation, click-drag to start edge - [x] PortItem: visible on node focus/hover, colored by relation, click-drag to start edge
- [ ] Interactive edge creation with live preview, drop on compatible port only (highlight legal targets) - [x] Interactive edge creation with live preview, drop on compatible port only (highlight legal targets)
- [ ] EdgeItem: styles — line (solid/dash/dot, width, color), head/tail decorations (none, arrow, circle, diamond, bar), title label - [x] EdgeItem: styles — line (solid/dash/dot, width, color), head/tail decorations (none, arrow, circle, diamond, bar), title label
- [ ] Color schemes: light / dark / high-contrast; per-relation edge & port colors - [x] Color schemes: light / dark / high-contrast; per-relation edge & port colors
### 6. Application UI ### 6. Application UI
- [ ] Node palette: grouped tree with SVG icons, drag & drop onto canvas (mime-encoded type id) - [x] Node palette: grouped tree with SVG icons, drag & drop onto canvas (mime-encoded type id)
- [ ] Property panel: grouped editors per selected node/edge — line edit, spin boxes, combo (enum), catalogue pickers (single/multi), color button, string-list editor - [x] Property panel: grouped editors per selected node/edge — line edit, spin boxes, combo (enum), catalogue pickers (single/multi), color button, string-list editor
- [ ] Settings dialog: grid size, snap, intersection arcs, color scheme - [x] Settings dialog: grid size, snap, intersection arcs, color scheme
- [ ] Toolbar/menus, status bar with cursor position & zoom - [x] Toolbar/menus, status bar with cursor position & zoom
### 7. Editing UX (added features beyond the brief) ### 7. Editing UX (added features beyond the brief)
- [ ] Node dragging (multi-selection), rubber-band selection - [x] Node dragging (multi-selection), rubber-band selection
- [ ] Zoom with Ctrl+wheel (cursor-anchored), fit-to-view, pan with middle mouse / space - [x] Zoom with Ctrl+wheel (cursor-anchored), fit-to-view, pan with middle mouse
- [ ] Undo/redo for all edits (add, delete, move, connect, property change) - [x] Undo/redo for all edits (add, delete, move, connect, property change)
- [ ] Copy/paste/duplicate with offset; Delete removes nodes + attached edges - [x] Copy/paste/duplicate with offset; Delete removes nodes + attached edges
- [ ] Context menus on canvas/node/edge - [x] Context menus on canvas/node/edge
- [ ] Save/load document as JSON; window state persistence - [x] Save/load document as JSON; window state persistence
- [ ] Keyboard shortcuts (standard set) - [x] Keyboard shortcuts (standard set)
### 8. Flow simulation mock ### 8. Flow simulation mock
- [ ] FlowSolver: sources/sinks from node types, conservation-based distribution splitting flow by pipe conductance (diameter-driven), per-edge signed flow rate - [x] FlowSolver: sources/sinks from node types, conservation-based distribution splitting flow by pipe conductance (diameter-driven), per-edge signed flow rate
- [ ] Animation: moving dashes along edge path; direction = flow sign, speed & density ∝ volume - [x] Animation: moving dashes along edge path; direction = flow sign, speed & density ∝ volume
- [ ] Run/stop simulation from toolbar; solver results shown in edge properties - [x] Run/stop simulation from toolbar; solver results shown in edge properties
### 9. Assets ### 9. Assets
- [ ] SVG set: source, tank, pump, valve, consumer, junction, generator, transformer, switch - [x] SVG set: source, tank, pump, valve, consumer, junction, generator, transformer, switch
- [ ] Node-type JSON with port configs; sample catalogues (pipe series, materials) - [x] Node-type JSON with port configs; sample catalogues (pipe series, materials)
- [ ] Sample document for demo - [x] Sample document for demo
### 10. Tests (Qt Test, offscreen) ### 10. Tests (Qt Test, offscreen)
- [ ] Model: create/connect validation matrix, port occupancy, delete cascades - [x] Model: create/connect validation matrix, port occupancy, delete cascades
- [ ] Properties: set/get, type safety, serialization round-trip - [x] Properties: set/get, type safety, serialization round-trip
- [ ] Router: orthogonality, grid alignment, intersection arcs on/off - [x] Router: orthogonality, grid alignment, intersection arcs on/off
- [ ] Solver: conservation at junctions, distribution by diameter - [x] Solver: conservation at junctions, distribution by diameter
- [ ] Undo/redo scenarios - [x] Undo/redo scenarios
- [ ] GUI: drop node from palette, drag node snapping, interactive connect, edge selection → property panel - [x] GUI: drop node from palette, drag node snapping, interactive connect, edge selection → property panel
### 11. Delivery ### 11. Delivery
- [ ] All tests green in CI-able script (`ctest`) - [x] All tests green in CI-able script (`ctest`)
- [ ] README with build/run instructions and screenshots - [x] README with build/run instructions and screenshots
- [ ] Conventional atomic commits per module - [x] Conventional atomic commits per module

View File

@ -0,0 +1,162 @@
{
"relations": [
{ "id": "water", "name": "Water", "color": "#1c78c0", "darkColor": "#4aa3e8" },
{ "id": "power", "name": "Power", "color": "#d99000", "darkColor": "#f0b429" }
],
"catalogues": [
{
"id": "pipe_series",
"name": "Pipe series",
"items": [
{ "id": "dn50", "name": "DN50 Steel", "attributes": { "diameter": 50, "material": "steel" } },
{ "id": "dn80", "name": "DN80 Steel", "attributes": { "diameter": 80, "material": "steel" } },
{ "id": "dn100", "name": "DN100 Steel", "attributes": { "diameter": 100, "material": "steel" } },
{ "id": "dn150", "name": "DN150 Steel", "attributes": { "diameter": 150, "material": "steel" } },
{ "id": "pe63", "name": "PE63 Plastic", "attributes": { "diameter": 63, "material": "PE" } },
{ "id": "pe110", "name": "PE110 Plastic","attributes": { "diameter": 110, "material": "PE" } }
]
},
{
"id": "insulation",
"name": "Insulation options",
"items": [
{ "id": "foam", "name": "PU foam shell" },
{ "id": "wool", "name": "Mineral wool" },
{ "id": "heating", "name": "Heat tracing" },
{ "id": "coating", "name": "Anticorrosive coating" }
]
},
{
"id": "cable_series",
"name": "Cable series",
"items": [
{ "id": "cu16", "name": "Cu 16 mm²", "attributes": { "section": 16 } },
{ "id": "cu35", "name": "Cu 35 mm²", "attributes": { "section": 35 } },
{ "id": "al50", "name": "Al 50 mm²", "attributes": { "section": 50 } }
]
}
],
"edgeProperties": {
"water": [
{ "id": "length", "name": "Length", "group": "Physics", "type": "float", "default": 10.0, "unit": "m", "min": 0.1, "max": 100000 },
{ "id": "diameter", "name": "Diameter", "group": "Physics", "type": "float", "default": 100.0, "unit": "mm", "min": 10, "max": 2000 },
{ "id": "flowRate", "name": "Flow rate", "group": "Physics", "type": "float", "default": 0.0, "unit": "m³/h", "readOnly": true },
{ "id": "flowType", "name": "Flow type", "group": "Physics", "type": "enum", "values": ["Laminar", "Transitional", "Turbulent"], "default": "Laminar" },
{ "id": "series", "name": "Pipe series", "group": "Physics", "type": "catalogueItem", "catalogue": "pipe_series" },
{ "id": "extras", "name": "Extras", "group": "Physics", "type": "catalogueItems", "catalogue": "insulation", "default": [] }
],
"power": [
{ "id": "length", "name": "Length", "group": "Physics", "type": "float", "default": 10.0, "unit": "m", "min": 0.1, "max": 100000 },
{ "id": "series", "name": "Cable", "group": "Physics", "type": "catalogueItem", "catalogue": "cable_series" },
{ "id": "voltage", "name": "Voltage", "group": "Physics", "type": "enum", "values": ["0.4 kV", "6 kV", "10 kV"], "default": "0.4 kV" },
{ "id": "flowRate","name": "Load", "group": "Physics", "type": "float", "default": 0.0, "unit": "kW", "readOnly": true }
]
},
"nodeTypes": [
{
"id": "water_source", "name": "Source", "group": "Water supply",
"svg": ":/svg/water_source.svg", "size": [60, 60],
"ports": [
{ "id": "out", "relation": "water", "direction": "out", "pos": [1.0, 0.5], "side": "right", "maxConnections": 0 }
],
"properties": [
{ "id": "supply", "name": "Supply", "group": "Physics", "type": "float", "default": 10.0, "unit": "m³/h", "min": 0, "max": 100000 },
{ "id": "elevation", "name": "Elevation", "group": "Physics", "type": "float", "default": 0.0, "unit": "m" }
]
},
{
"id": "tank", "name": "Tank", "group": "Water supply",
"svg": ":/svg/tank.svg", "size": [60, 60],
"ports": [
{ "id": "in", "relation": "water", "direction": "in", "pos": [0.0, 0.5], "side": "left" },
{ "id": "out", "relation": "water", "direction": "out", "pos": [1.0, 0.5], "side": "right" }
],
"properties": [
{ "id": "volume", "name": "Volume", "group": "Physics", "type": "float", "default": 50.0, "unit": "m³", "min": 0, "max": 100000 }
]
},
{
"id": "pump", "name": "Pump", "group": "Water supply",
"svg": ":/svg/pump.svg", "size": [60, 60],
"ports": [
{ "id": "in", "relation": "water", "direction": "in", "pos": [0.0, 0.5], "side": "left" },
{ "id": "out", "relation": "water", "direction": "out", "pos": [1.0, 0.5], "side": "right" },
{ "id": "pwr", "relation": "power", "direction": "in", "pos": [0.5, 0.0], "side": "top" }
],
"properties": [
{ "id": "head", "name": "Head", "group": "Physics", "type": "float", "default": 20.0, "unit": "m", "min": 0, "max": 2000 },
{ "id": "power", "name": "Rated power","group": "Physics", "type": "float", "default": 5.5, "unit": "kW", "min": 0, "max": 10000 }
]
},
{
"id": "valve", "name": "Valve", "group": "Water supply",
"svg": ":/svg/valve.svg", "size": [50, 50],
"ports": [
{ "id": "in", "relation": "water", "direction": "inout", "pos": [0.0, 0.5], "side": "left" },
{ "id": "out", "relation": "water", "direction": "inout", "pos": [1.0, 0.5], "side": "right" }
],
"properties": [
{ "id": "state", "name": "State", "group": "Physics", "type": "enum", "values": ["Open", "Closed", "Throttled"], "default": "Open" }
]
},
{
"id": "junction", "name": "Junction", "group": "Water supply",
"svg": ":/svg/junction.svg", "size": [40, 40],
"ports": [
{ "id": "w", "relation": "water", "direction": "inout", "pos": [0.0, 0.5], "side": "left", "maxConnections": 0 },
{ "id": "e", "relation": "water", "direction": "inout", "pos": [1.0, 0.5], "side": "right", "maxConnections": 0 },
{ "id": "n", "relation": "water", "direction": "inout", "pos": [0.5, 0.0], "side": "top", "maxConnections": 0 },
{ "id": "s", "relation": "water", "direction": "inout", "pos": [0.5, 1.0], "side": "bottom", "maxConnections": 0 }
]
},
{
"id": "consumer", "name": "Consumer", "group": "Water supply",
"svg": ":/svg/consumer.svg", "size": [60, 60],
"ports": [
{ "id": "in", "relation": "water", "direction": "in", "pos": [0.0, 0.5], "side": "left" }
],
"properties": [
{ "id": "demand", "name": "Demand", "group": "Physics", "type": "float", "default": 10.0, "unit": "m³/h", "min": 0, "max": 100000 }
]
},
{
"id": "generator", "name": "Generator", "group": "Power",
"svg": ":/svg/generator.svg", "size": [60, 60],
"ports": [
{ "id": "out", "relation": "power", "direction": "out", "pos": [1.0, 0.5], "side": "right", "maxConnections": 0 }
],
"properties": [
{ "id": "supply", "name": "Output power", "group": "Physics", "type": "float", "default": 100.0, "unit": "kW", "min": 0, "max": 1000000 }
]
},
{
"id": "transformer", "name": "Transformer", "group": "Power",
"svg": ":/svg/transformer.svg", "size": [60, 60],
"ports": [
{ "id": "in", "relation": "power", "direction": "in", "pos": [0.0, 0.5], "side": "left" },
{ "id": "out", "relation": "power", "direction": "out", "pos": [1.0, 0.5], "side": "right", "maxConnections": 0 }
]
},
{
"id": "switch", "name": "Switch", "group": "Power",
"svg": ":/svg/switch.svg", "size": [50, 50],
"ports": [
{ "id": "a", "relation": "power", "direction": "inout", "pos": [0.0, 0.5], "side": "left" },
{ "id": "b", "relation": "power", "direction": "inout", "pos": [1.0, 0.5], "side": "right" }
],
"properties": [
{ "id": "state", "name": "State", "group": "Physics", "type": "enum", "values": ["Closed", "Open"], "default": "Closed" }
]
},
{
"id": "load", "name": "Load", "group": "Power",
"svg": ":/svg/consumer.svg", "size": [60, 60],
"ports": [
{ "id": "in", "relation": "power", "direction": "in", "pos": [0.0, 0.5], "side": "left" }
],
"properties": [
{ "id": "demand", "name": "Power demand", "group": "Physics", "type": "float", "default": 15.0, "unit": "kW", "min": 0, "max": 1000000 }
]
}
]
}

15
resources/resources.qrc Normal file
View File

@ -0,0 +1,15 @@
<RCC>
<qresource prefix="/">
<file>svg/water_source.svg</file>
<file>svg/tank.svg</file>
<file>svg/pump.svg</file>
<file>svg/valve.svg</file>
<file>svg/consumer.svg</file>
<file>svg/junction.svg</file>
<file>svg/generator.svg</file>
<file>svg/transformer.svg</file>
<file>svg/switch.svg</file>
<file>config/nodetypes.json</file>
<file>samples/waterworks.json</file>
</qresource>
</RCC>

View File

@ -0,0 +1,51 @@
{
"format": "pipediagram",
"version": 1,
"nodes": [
{ "id": "11111111-0000-0000-0000-000000000001", "type": "water_source", "pos": [-400, 0],
"props": { "title": "Intake", "supply": 24.0 } },
{ "id": "11111111-0000-0000-0000-000000000002", "type": "pump", "pos": [-240, 0],
"props": { "title": "P-101" } },
{ "id": "11111111-0000-0000-0000-000000000003", "type": "junction", "pos": [-70, 10] },
{ "id": "11111111-0000-0000-0000-000000000004", "type": "tank", "pos": [60, -160],
"props": { "title": "T-1" } },
{ "id": "11111111-0000-0000-0000-000000000005", "type": "consumer", "pos": [120, 0],
"props": { "title": "District A", "demand": 15.0 } },
{ "id": "11111111-0000-0000-0000-000000000006", "type": "consumer", "pos": [60, 160],
"props": { "title": "District B", "demand": 9.0 } },
{ "id": "11111111-0000-0000-0000-000000000007", "type": "generator", "pos": [-400, -200],
"props": { "title": "G-1" } },
{ "id": "11111111-0000-0000-0000-000000000008", "type": "load", "pos": [-60, -300],
"props": { "title": "Aux load", "demand": 20.0 } }
],
"edges": [
{ "id": "22222222-0000-0000-0000-000000000001",
"from": "11111111-0000-0000-0000-000000000001", "fromPort": "out",
"to": "11111111-0000-0000-0000-000000000002", "toPort": "in",
"props": { "diameter": 150, "length": 25 } },
{ "id": "22222222-0000-0000-0000-000000000002",
"from": "11111111-0000-0000-0000-000000000002", "fromPort": "out",
"to": "11111111-0000-0000-0000-000000000003", "toPort": "w",
"props": { "diameter": 150, "length": 40 } },
{ "id": "22222222-0000-0000-0000-000000000003",
"from": "11111111-0000-0000-0000-000000000003", "fromPort": "n",
"to": "11111111-0000-0000-0000-000000000004", "toPort": "in",
"props": { "diameter": 100, "length": 30, "title": "to tank" } },
{ "id": "22222222-0000-0000-0000-000000000004",
"from": "11111111-0000-0000-0000-000000000003", "fromPort": "e",
"to": "11111111-0000-0000-0000-000000000005", "toPort": "in",
"props": { "diameter": 100, "length": 60 } },
{ "id": "22222222-0000-0000-0000-000000000005",
"from": "11111111-0000-0000-0000-000000000003", "fromPort": "s",
"to": "11111111-0000-0000-0000-000000000006", "toPort": "in",
"props": { "diameter": 80, "length": 45 } },
{ "id": "22222222-0000-0000-0000-000000000006",
"from": "11111111-0000-0000-0000-000000000007", "fromPort": "out",
"to": "11111111-0000-0000-0000-000000000002", "toPort": "pwr",
"props": {} },
{ "id": "22222222-0000-0000-0000-000000000007",
"from": "11111111-0000-0000-0000-000000000007", "fromPort": "out",
"to": "11111111-0000-0000-0000-000000000008", "toPort": "in",
"props": { "title": "aux feeder" } }
]
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M12 30 L32 12 L52 30 V52 a4 4 0 0 1 -4 4 H16 a4 4 0 0 1 -4 -4 Z"
fill="#ede7f6" stroke="#5e35b1" stroke-width="3" stroke-linejoin="round"/>
<rect x="26" y="38" width="12" height="18" fill="#5e35b1"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="25" fill="#fff8e1" stroke="#d99000" stroke-width="3"/>
<path d="M36 12 L22 36 h9 L27 52 L43 28 h-9 Z" fill="#d99000" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M26 6 h12 v20 h20 v12 h-20 v20 h-12 v-20 h-20 v-12 h20 Z"
fill="#eceff1" stroke="#455a64" stroke-width="3" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="7" fill="#455a64"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

5
resources/svg/pump.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="24" fill="#e8f5e9" stroke="#2e7d32" stroke-width="3"/>
<path d="M32 32 L52 20 L52 44 Z" fill="#2e7d32"/>
<circle cx="32" cy="32" r="6" fill="#2e7d32"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

5
resources/svg/switch.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="10" cy="32" r="5" fill="#d99000"/>
<circle cx="54" cy="32" r="5" fill="#d99000"/>
<line x1="14" y1="32" x2="46" y2="14" stroke="#b26a00" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

6
resources/svg/tank.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="10" y="12" width="44" height="40" rx="6" fill="#e3f2fd" stroke="#455a64" stroke-width="3"/>
<path d="M10 34 q 11 -6 22 0 t 22 0 v 12 a6 6 0 0 1 -6 6 h -32 a6 6 0 0 1 -6 -6 Z"
fill="#64a7d8" stroke="none"/>
<line x1="10" y1="12" x2="54" y2="12" stroke="#455a64" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="24" cy="32" r="17" fill="none" stroke="#d99000" stroke-width="3"/>
<circle cx="40" cy="32" r="17" fill="none" stroke="#b26a00" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

6
resources/svg/valve.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M6 18 L32 32 L6 46 Z" fill="#fff3e0" stroke="#e65100" stroke-width="3" stroke-linejoin="round"/>
<path d="M58 18 L32 32 L58 46 Z" fill="#fff3e0" stroke="#e65100" stroke-width="3" stroke-linejoin="round"/>
<line x1="32" y1="32" x2="32" y2="12" stroke="#e65100" stroke-width="3"/>
<line x1="22" y1="12" x2="42" y2="12" stroke="#e65100" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="26" fill="#e3f2fd" stroke="#1c78c0" stroke-width="3"/>
<path d="M32 16 C 24 28, 20 34, 20 41 a12 12 0 0 0 24 0 C 44 34, 40 28, 32 16 Z"
fill="#1c78c0" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -8,6 +8,48 @@ add_library(diagcore STATIC
core/NetworkModel.cpp core/NetworkModel.cpp
core/Router.h core/Router.h
core/Router.cpp core/Router.cpp
core/FlowSolver.h
core/FlowSolver.cpp
core/JsonIo.h
core/JsonIo.cpp
) )
target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui)
# Scene: QGraphicsScene items and interaction.
add_library(diagscene STATIC
scene/Theme.h
scene/Theme.cpp
scene/Commands.h
scene/Commands.cpp
scene/DiagramScene.h
scene/DiagramScene.cpp
scene/NodeItem.h
scene/NodeItem.cpp
scene/PortItem.h
scene/PortItem.cpp
scene/EdgeItem.h
scene/EdgeItem.cpp
scene/GridView.h
scene/GridView.cpp
)
target_link_libraries(diagscene PUBLIC diagcore Qt6::Widgets Qt6::Svg)
# App: main window and panels.
add_library(diagapp STATIC
app/MainWindow.h
app/MainWindow.cpp
app/NodePalette.h
app/NodePalette.cpp
app/PropertyPanel.h
app/PropertyPanel.cpp
app/SettingsDialog.h
app/SettingsDialog.cpp
)
target_link_libraries(diagapp PUBLIC diagscene)
# Resources live on the executable: a static lib would drop the unreferenced
# resource-init object at link time.
qt_add_resources(APP_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../resources/resources.qrc)
qt_add_executable(pipediagram main.cpp ${APP_RESOURCES})
target_link_libraries(pipediagram PRIVATE diagapp)

272
src/app/MainWindow.cpp Normal file
View File

@ -0,0 +1,272 @@
#include "MainWindow.h"
#include "NodePalette.h"
#include "PropertyPanel.h"
#include "core/FlowSolver.h"
#include "core/JsonIo.h"
#include "scene/DiagramScene.h"
#include "scene/GridView.h"
#include <QApplication>
#include <QCloseEvent>
#include <QDockWidget>
#include <QFileDialog>
#include <QLabel>
#include <QMenuBar>
#include <QMessageBox>
#include <QSettings>
#include <QStatusBar>
#include <QToolBar>
namespace diag {
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent)
{
setWindowTitle(tr("PipeDiagram"));
resize(1280, 800);
TypeRegistry& registry = TypeRegistry::instance();
if (registry.nodeTypes().isEmpty()) {
QString err;
if (!registry.loadFromFile(QStringLiteral(":/config/nodetypes.json"), &err))
QMessageBox::warning(this, tr("Configuration"),
tr("Failed to load node types: %1").arg(err));
}
m_model = new NetworkModel(&registry, this);
m_undoStack = new QUndoStack(this);
m_scene = new DiagramScene(m_model, m_undoStack, this);
m_view = new GridView(m_scene, this);
setCentralWidget(m_view);
m_palette = new NodePalette(&registry, this);
auto* paletteDock = new QDockWidget(tr("Node palette"), this);
paletteDock->setObjectName(QStringLiteral("paletteDock"));
paletteDock->setWidget(m_palette);
addDockWidget(Qt::LeftDockWidgetArea, paletteDock);
m_properties = new PropertyPanel(this);
m_properties->setScene(m_scene);
auto* propsDock = new QDockWidget(tr("Properties"), this);
propsDock->setObjectName(QStringLiteral("propsDock"));
propsDock->setWidget(m_properties);
addDockWidget(Qt::RightDockWidgetArea, propsDock);
buildActions();
auto* cursorLabel = new QLabel;
auto* zoomLabel = new QLabel(QStringLiteral("100%"));
statusBar()->addPermanentWidget(cursorLabel);
statusBar()->addPermanentWidget(zoomLabel);
connect(m_view, &GridView::cursorMoved, this, [cursorLabel](QPointF p) {
cursorLabel->setText(QStringLiteral("x: %1 y: %2").arg(int(p.x())).arg(int(p.y())));
});
connect(m_view, &GridView::zoomChanged, this, [zoomLabel](double z) {
zoomLabel->setText(QStringLiteral("%1%").arg(int(z * 100)));
});
connect(m_scene, &DiagramScene::connectionMessage, this,
[this](const QString& text) { statusBar()->showMessage(text, 4000); });
m_settings.load();
applySettings(m_settings);
QSettings s;
restoreGeometry(s.value(QStringLiteral("window/geometry")).toByteArray());
restoreState(s.value(QStringLiteral("window/state")).toByteArray());
}
void MainWindow::buildActions()
{
auto* fileMenu = menuBar()->addMenu(tr("&File"));
auto* toolbar = addToolBar(tr("Main"));
toolbar->setObjectName(QStringLiteral("mainToolbar"));
QAction* newAct = fileMenu->addAction(tr("&New"), QKeySequence::New, this,
&MainWindow::newDocument);
QAction* openAct = fileMenu->addAction(tr("&Open…"), QKeySequence::Open, this,
&MainWindow::openDocument);
QAction* saveAct = fileMenu->addAction(tr("&Save"), QKeySequence::Save, this,
&MainWindow::saveDocument);
fileMenu->addAction(tr("Save &As…"), QKeySequence::SaveAs, this,
&MainWindow::saveDocumentAs);
fileMenu->addAction(tr("Load Sa&mple"), this, &MainWindow::loadSample);
fileMenu->addSeparator();
fileMenu->addAction(tr("&Quit"), QKeySequence::Quit, this, &QWidget::close);
auto* editMenu = menuBar()->addMenu(tr("&Edit"));
QAction* undoAct = m_undoStack->createUndoAction(this, tr("&Undo"));
undoAct->setShortcut(QKeySequence::Undo);
QAction* redoAct = m_undoStack->createRedoAction(this, tr("&Redo"));
redoAct->setShortcut(QKeySequence::Redo);
editMenu->addAction(undoAct);
editMenu->addAction(redoAct);
editMenu->addSeparator();
editMenu->addAction(tr("Cu&t"), QKeySequence::Cut, m_scene, &DiagramScene::cutSelection);
editMenu->addAction(tr("&Copy"), QKeySequence::Copy, m_scene,
&DiagramScene::copySelection);
editMenu->addAction(tr("&Paste"), QKeySequence::Paste, m_scene, &DiagramScene::paste);
QAction* deleteAct = editMenu->addAction(tr("&Delete"), QKeySequence::Delete, m_scene,
&DiagramScene::deleteSelection);
editMenu->addAction(tr("Select &All"), QKeySequence::SelectAll, m_scene,
&DiagramScene::selectAll);
editMenu->addSeparator();
editMenu->addAction(tr("Se&ttings…"), QKeySequence::Preferences, this,
&MainWindow::showSettings);
auto* viewMenu = menuBar()->addMenu(tr("&View"));
viewMenu->addAction(tr("Zoom &In"), QKeySequence::ZoomIn, m_view, &GridView::zoomIn);
viewMenu->addAction(tr("Zoom &Out"), QKeySequence::ZoomOut, m_view, &GridView::zoomOut);
QAction* fitAct = viewMenu->addAction(tr("Zoom to &Fit"), QKeySequence(Qt::Key_F), m_view,
&GridView::zoomToFit);
viewMenu->addAction(tr("&Reset Zoom"), QKeySequence(Qt::CTRL | Qt::Key_0), m_view,
&GridView::zoomReset);
auto* simMenu = menuBar()->addMenu(tr("&Simulation"));
m_simulateAction = simMenu->addAction(tr("&Run Flow Simulation"));
m_simulateAction->setCheckable(true);
m_simulateAction->setShortcut(QKeySequence(Qt::Key_F5));
connect(m_simulateAction, &QAction::toggled, this, &MainWindow::runSimulation);
toolbar->addAction(newAct);
toolbar->addAction(openAct);
toolbar->addAction(saveAct);
toolbar->addSeparator();
toolbar->addAction(undoAct);
toolbar->addAction(redoAct);
toolbar->addAction(deleteAct);
toolbar->addSeparator();
toolbar->addAction(fitAct);
toolbar->addAction(m_simulateAction);
}
void MainWindow::applySettings(const EditorSettings& settings)
{
m_settings = settings;
m_scene->setRouteConfig(settings.route);
m_scene->setTheme(Theme::make(settings.scheme));
settings.save();
}
void MainWindow::showSettings()
{
SettingsDialog dialog(m_settings, this);
if (dialog.exec() == QDialog::Accepted)
applySettings(dialog.settings());
}
bool MainWindow::maybeSave()
{
if (m_undoStack->isClean())
return true;
const auto answer = QMessageBox::question(
this, tr("Unsaved changes"), tr("The diagram has unsaved changes. Save them?"),
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
if (answer == QMessageBox::Save) {
saveDocument();
return m_undoStack->isClean();
}
return answer == QMessageBox::Discard;
}
void MainWindow::newDocument()
{
if (!maybeSave())
return;
m_model->clear();
m_undoStack->clear();
m_currentFile.clear();
m_simulateAction->setChecked(false);
}
void MainWindow::openDocument()
{
if (!maybeSave())
return;
const QString path = QFileDialog::getOpenFileName(this, tr("Open diagram"), {},
tr("Diagrams (*.json)"));
if (path.isEmpty())
return;
QString err;
if (!JsonIo::load(m_model, path, &err)) {
QMessageBox::warning(this, tr("Open failed"), err);
return;
}
m_undoStack->clear();
m_currentFile = path;
m_simulateAction->setChecked(false);
m_view->zoomToFit();
}
void MainWindow::saveDocument()
{
if (m_currentFile.isEmpty()) {
saveDocumentAs();
return;
}
QString err;
if (!JsonIo::save(*m_model, m_currentFile, &err))
QMessageBox::warning(this, tr("Save failed"), err);
else
m_undoStack->setClean();
}
void MainWindow::saveDocumentAs()
{
const QString path = QFileDialog::getSaveFileName(this, tr("Save diagram"), {},
tr("Diagrams (*.json)"));
if (path.isEmpty())
return;
m_currentFile = path;
saveDocument();
}
void MainWindow::loadSample()
{
if (!maybeSave())
return;
QString err;
if (!JsonIo::load(m_model, QStringLiteral(":/samples/waterworks.json"), &err)) {
QMessageBox::warning(this, tr("Sample"), err);
return;
}
m_undoStack->clear();
m_currentFile.clear();
m_view->zoomToFit();
}
void MainWindow::setSimulationRunning(bool running)
{
m_simulateAction->setChecked(running);
}
void MainWindow::runSimulation(bool enabled)
{
if (enabled) {
// Mock calculation: distribute flow in every relation network, then
// publish rates to the read-only flowRate property and animate.
for (const Relation& rel : m_model->registry()->relations()) {
const FlowResult result = FlowSolver::solve(*m_model, rel.id);
if (!result.ok && !result.message.isEmpty())
statusBar()->showMessage(tr("%1: %2").arg(rel.name, result.message), 5000);
FlowSolver::apply(*m_model, result);
}
statusBar()->showMessage(tr("Flow simulation running"), 3000);
} else {
statusBar()->showMessage(tr("Flow simulation stopped"), 3000);
}
m_scene->setFlowAnimationEnabled(enabled);
}
void MainWindow::closeEvent(QCloseEvent* event)
{
if (!maybeSave()) {
event->ignore();
return;
}
QSettings s;
s.setValue(QStringLiteral("window/geometry"), saveGeometry());
s.setValue(QStringLiteral("window/state"), saveState());
event->accept();
}
} // namespace diag

52
src/app/MainWindow.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include "SettingsDialog.h"
#include <QMainWindow>
#include <QUndoStack>
namespace diag {
class DiagramScene;
class GridView;
class NetworkModel;
class NodePalette;
class PropertyPanel;
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
DiagramScene* scene() const { return m_scene; }
NetworkModel* model() const { return m_model; }
void loadSample();
void setSimulationRunning(bool running);
protected:
void closeEvent(QCloseEvent* event) override;
private:
void buildActions();
void applySettings(const EditorSettings& settings);
void newDocument();
void openDocument();
void saveDocument();
void saveDocumentAs();
bool maybeSave();
void runSimulation(bool enabled);
void showSettings();
NetworkModel* m_model;
QUndoStack* m_undoStack;
DiagramScene* m_scene;
GridView* m_view;
NodePalette* m_palette;
PropertyPanel* m_properties;
EditorSettings m_settings;
QString m_currentFile;
QAction* m_simulateAction = nullptr;
};
} // namespace diag

82
src/app/NodePalette.cpp Normal file
View File

@ -0,0 +1,82 @@
#include "NodePalette.h"
#include "core/TypeRegistry.h"
#include "scene/GridView.h"
#include <QDrag>
#include <QMimeData>
#include <QPainter>
#include <QSvgRenderer>
namespace diag {
namespace {
QIcon iconFor(const NodeType* type)
{
QSvgRenderer renderer(type->svgPath);
QPixmap pm(32, 32);
pm.fill(Qt::transparent);
if (renderer.isValid()) {
QPainter p(&pm);
renderer.render(&p, QRectF(0, 0, 32, 32));
}
return QIcon(pm);
}
} // namespace
NodePalette::NodePalette(TypeRegistry* registry, QWidget* parent)
: QTreeWidget(parent), m_registry(registry)
{
setHeaderHidden(true);
setDragEnabled(true);
setIconSize({28, 28});
setRootIsDecorated(false);
setIndentation(12);
reload();
}
void NodePalette::reload()
{
clear();
QHash<QString, QTreeWidgetItem*> groups;
for (const QString& group : m_registry->nodeGroups()) {
auto* item = new QTreeWidgetItem(this, {group});
item->setFlags(Qt::ItemIsEnabled);
QFont f = item->font(0);
f.setBold(true);
item->setFont(0, f);
groups.insert(group, item);
}
for (const NodeType* type : m_registry->nodeTypes()) {
auto* item = new QTreeWidgetItem(groups.value(type->group), {type->name});
item->setIcon(0, iconFor(type));
item->setData(0, Qt::UserRole, type->id);
item->setToolTip(0, QStringLiteral("%1 — %2 port(s). Drag onto the canvas.")
.arg(type->name)
.arg(type->ports.size()));
}
expandAll();
}
void NodePalette::startDrag(Qt::DropActions)
{
QTreeWidgetItem* item = currentItem();
if (!item)
return;
const QString typeId = item->data(0, Qt::UserRole).toString();
if (typeId.isEmpty())
return; // group header
auto* mime = new QMimeData();
mime->setData(QLatin1String(GridView::kNodeTypeMime), typeId.toUtf8());
auto* drag = new QDrag(this);
drag->setMimeData(mime);
drag->setPixmap(item->icon(0).pixmap(32, 32));
drag->setHotSpot({16, 16});
drag->exec(Qt::CopyAction);
}
} // namespace diag

25
src/app/NodePalette.h Normal file
View File

@ -0,0 +1,25 @@
#pragma once
#include <QTreeWidget>
namespace diag {
class TypeRegistry;
// Grouped list of node types with SVG icons; items are dragged onto the
// canvas (mime: GridView::kNodeTypeMime carrying the type id).
class NodePalette : public QTreeWidget {
Q_OBJECT
public:
explicit NodePalette(TypeRegistry* registry, QWidget* parent = nullptr);
void reload();
protected:
void startDrag(Qt::DropActions supportedActions) override;
private:
TypeRegistry* m_registry;
};
} // namespace diag

303
src/app/PropertyPanel.cpp Normal file
View File

@ -0,0 +1,303 @@
#include "PropertyPanel.h"
#include "scene/Commands.h"
#include "scene/DiagramScene.h"
#include "scene/EdgeItem.h"
#include "scene/NodeItem.h"
#include "core/NetworkModel.h"
#include <QColorDialog>
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QPushButton>
#include <QSpinBox>
#include <QToolButton>
#include <QVBoxLayout>
namespace diag {
PropertyPanel::PropertyPanel(QWidget* parent) : QScrollArea(parent)
{
setWidgetResizable(true);
setMinimumWidth(260);
showPlaceholder(tr("Select a node or an edge\nto edit its properties"));
}
void PropertyPanel::setScene(DiagramScene* scene)
{
if (m_scene)
disconnect(m_scene, nullptr, this, nullptr);
m_scene = scene;
connect(scene, &DiagramScene::selectionChanged, this, &PropertyPanel::onSelectionChanged);
// Rebuild values (not widgets) when the selected element changes elsewhere
// (undo, solver run, ...).
connect(scene->model(), &NetworkModel::nodeChanged, this, [this](QUuid id) {
if (m_target == Target::Node && id == m_id && !m_updating)
rebuild();
});
connect(scene->model(), &NetworkModel::edgeChanged, this, [this](QUuid id) {
if (m_target == Target::Edge && id == m_id && !m_updating)
rebuild();
});
connect(scene->model(), &NetworkModel::flowChanged, this, [this](QUuid id) {
if (m_target == Target::Edge && id == m_id)
rebuild();
});
onSelectionChanged();
}
void PropertyPanel::onSelectionChanged()
{
m_target = Target::None;
m_id = {};
if (m_scene) {
const auto items = m_scene->selectedItems();
if (items.size() == 1) {
if (auto* n = dynamic_cast<NodeItem*>(items.first())) {
m_target = Target::Node;
m_id = n->nodeId();
} else if (auto* e = dynamic_cast<EdgeItem*>(items.first())) {
m_target = Target::Edge;
m_id = e->edgeId();
}
} else if (items.size() > 1) {
showPlaceholder(tr("%1 elements selected").arg(items.size()));
return;
}
}
if (m_target == Target::None) {
showPlaceholder(tr("Select a node or an edge\nto edit its properties"));
return;
}
rebuild();
}
void PropertyPanel::showPlaceholder(const QString& text)
{
auto* placeholder = new QLabel(text);
placeholder->setAlignment(Qt::AlignCenter);
placeholder->setWordWrap(true);
placeholder->setStyleSheet(QStringLiteral("color: gray;"));
setWidget(placeholder);
m_content = placeholder;
}
QVariant PropertyPanel::currentValue(const QString& key) const
{
NetworkModel* model = m_scene->model();
if (m_target == Target::Node)
return model->nodeProperty(m_id, key);
// Solver result lives on the edge itself, exposed via the read-only spec.
if (key == QLatin1String("flowRate")) {
if (const Edge* e = model->edge(m_id))
return e->flowRate;
}
return model->edgeProperty(m_id, key);
}
void PropertyPanel::applyValue(const QString& key, const QVariant& value)
{
if (m_updating)
return;
m_updating = true;
m_scene->undoStack()->push(new SetPropertyCommand(
m_scene->model(),
m_target == Target::Node ? SetPropertyCommand::NodeTarget : SetPropertyCommand::EdgeTarget,
m_id, key, value));
m_updating = false;
}
void PropertyPanel::rebuild()
{
NetworkModel* model = m_scene->model();
QList<PropertySpec> specs;
QString header;
if (m_target == Target::Node) {
const Node* n = model->node(m_id);
if (!n)
return;
const NodeType* type = model->nodeTypeOf(m_id);
specs = model->registry()->nodeProperties(n->typeId);
header = tr("Node: %1").arg(type ? type->name : n->typeId);
} else {
const Edge* e = model->edge(m_id);
if (!e)
return;
const Relation* rel = model->registry()->relation(e->relation);
specs = model->registry()->edgeProperties(e->relation);
header = tr("Edge: %1 line").arg(rel ? rel->name : e->relation);
}
auto* content = new QWidget;
auto* layout = new QVBoxLayout(content);
layout->setContentsMargins(8, 8, 8, 8);
auto* title = new QLabel(header);
QFont f = title->font();
f.setBold(true);
title->setFont(f);
layout->addWidget(title);
// Group boxes in spec order, keeping first-seen group order.
QStringList groupOrder;
for (const auto& s : specs)
if (!groupOrder.contains(s.group))
groupOrder.append(s.group);
for (const QString& group : groupOrder) {
auto* box = new QGroupBox(group);
auto* form = new QFormLayout(box);
form->setLabelAlignment(Qt::AlignLeft);
for (const auto& spec : specs) {
if (spec.group != group)
continue;
QString label = spec.name;
if (!spec.unit.isEmpty())
label += QStringLiteral(", %1").arg(spec.unit);
form->addRow(label, createEditor(spec));
}
layout->addWidget(box);
}
layout->addStretch();
setWidget(content);
m_content = content;
}
QWidget* PropertyPanel::createEditor(const PropertySpec& spec)
{
const QVariant value = currentValue(spec.id);
const bool unbounded = spec.min == spec.max;
QWidget* editor = nullptr;
switch (spec.type) {
case PropertyType::String: {
auto* w = new QLineEdit(value.toString());
connect(w, &QLineEdit::editingFinished, this,
[this, spec, w] { applyValue(spec.id, w->text()); });
editor = w;
break;
}
case PropertyType::Int: {
auto* w = new QSpinBox;
w->setRange(unbounded ? -1000000000 : int(spec.min), unbounded ? 1000000000 : int(spec.max));
w->setValue(value.toInt());
connect(w, &QSpinBox::editingFinished, this,
[this, spec, w] { applyValue(spec.id, w->value()); });
editor = w;
break;
}
case PropertyType::Float: {
auto* w = new QDoubleSpinBox;
w->setRange(unbounded ? -1e12 : spec.min, unbounded ? 1e12 : spec.max);
w->setDecimals(3);
w->setValue(value.toDouble());
connect(w, &QDoubleSpinBox::editingFinished, this,
[this, spec, w] { applyValue(spec.id, w->value()); });
editor = w;
break;
}
case PropertyType::Enum: {
auto* w = new QComboBox;
w->addItems(spec.enumValues);
w->setCurrentText(value.toString());
connect(w, &QComboBox::activated, this,
[this, spec, w] { applyValue(spec.id, w->currentText()); });
editor = w;
break;
}
case PropertyType::CatalogueItem: {
auto* w = new QComboBox;
w->addItem(tr(""), QString());
if (const Catalogue* cat = m_scene->model()->registry()->catalogue(spec.catalogueId))
for (const auto& item : cat->items)
w->addItem(item.name, item.id);
const int idx = w->findData(value.toString());
w->setCurrentIndex(qMax(0, idx));
connect(w, &QComboBox::activated, this,
[this, spec, w] { applyValue(spec.id, w->currentData().toString()); });
editor = w;
break;
}
case PropertyType::CatalogueItems: {
auto* w = new QListWidget;
w->setMaximumHeight(96);
const QStringList selected = value.toStringList();
if (const Catalogue* cat = m_scene->model()->registry()->catalogue(spec.catalogueId)) {
for (const auto& item : cat->items) {
auto* li = new QListWidgetItem(item.name, w);
li->setData(Qt::UserRole, item.id);
li->setFlags(li->flags() | Qt::ItemIsUserCheckable);
li->setCheckState(selected.contains(item.id) ? Qt::Checked : Qt::Unchecked);
}
}
connect(w, &QListWidget::itemChanged, this, [this, spec, w] {
QStringList ids;
for (int i = 0; i < w->count(); ++i)
if (w->item(i)->checkState() == Qt::Checked)
ids.append(w->item(i)->data(Qt::UserRole).toString());
applyValue(spec.id, ids);
});
editor = w;
break;
}
case PropertyType::Color: {
auto* row = new QWidget;
auto* layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
auto* button = new QPushButton;
auto* clear = new QToolButton;
clear->setText(QStringLiteral(""));
clear->setToolTip(tr("Reset to automatic color"));
auto sync = [button](const QString& name) {
const bool valid = QColor::isValidColorName(name);
button->setText(valid ? name : QObject::tr("Auto"));
button->setStyleSheet(valid
? QStringLiteral("background-color: %1;").arg(name)
: QString());
};
sync(value.toString());
connect(button, &QPushButton::clicked, this, [this, spec, button, sync] {
const QColor initial(currentValue(spec.id).toString());
const QColor c = QColorDialog::getColor(initial.isValid() ? initial : Qt::gray,
this, tr("Choose color"));
if (c.isValid()) {
applyValue(spec.id, c.name());
sync(c.name());
}
});
connect(clear, &QToolButton::clicked, this, [this, spec, sync] {
applyValue(spec.id, QString());
sync(QString());
});
layout->addWidget(button, 1);
layout->addWidget(clear);
editor = row;
break;
}
case PropertyType::ValueList: {
auto* w = new QLineEdit(value.toStringList().join(QStringLiteral(", ")));
w->setPlaceholderText(tr("value1, value2, …"));
w->setToolTip(tr("Comma-separated list of values"));
connect(w, &QLineEdit::editingFinished, this, [this, spec, w] {
QStringList values;
for (const QString& part : w->text().split(QLatin1Char(',')))
if (!part.trimmed().isEmpty())
values.append(part.trimmed());
applyValue(spec.id, values);
});
editor = w;
break;
}
}
if (spec.readOnly)
editor->setEnabled(false);
return editor;
}
} // namespace diag

42
src/app/PropertyPanel.h Normal file
View File

@ -0,0 +1,42 @@
#pragma once
#include "core/Types.h"
#include <QScrollArea>
#include <QUuid>
class QVBoxLayout;
namespace diag {
class DiagramScene;
// Right-side panel: grouped, typed editors for the selected node or edge.
// Edits are pushed to the undo stack; values refresh on model changes.
class PropertyPanel : public QScrollArea {
Q_OBJECT
public:
explicit PropertyPanel(QWidget* parent = nullptr);
void setScene(DiagramScene* scene);
public slots:
void onSelectionChanged();
private:
enum class Target { None, Node, Edge };
void rebuild();
void showPlaceholder(const QString& text);
QVariant currentValue(const QString& key) const;
void applyValue(const QString& key, const QVariant& value);
QWidget* createEditor(const PropertySpec& spec);
DiagramScene* m_scene = nullptr;
Target m_target = Target::None;
QUuid m_id;
QWidget* m_content = nullptr;
bool m_updating = false;
};
} // namespace diag

View File

@ -0,0 +1,82 @@
#include "SettingsDialog.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QSettings>
#include <QSpinBox>
namespace diag {
void EditorSettings::load()
{
QSettings s;
route.gridStep = s.value(QStringLiteral("grid/step"), 20.0).toDouble();
route.snapToGrid = s.value(QStringLiteral("grid/snap"), true).toBool();
route.arcOnIntersection = s.value(QStringLiteral("edges/arcOnIntersection"), true).toBool();
route.arcRadius = s.value(QStringLiteral("edges/arcRadius"), 6.0).toDouble();
scheme = Theme::schemeFromName(
s.value(QStringLiteral("view/colorScheme"), QStringLiteral("Light")).toString());
}
void EditorSettings::save() const
{
QSettings s;
s.setValue(QStringLiteral("grid/step"), route.gridStep);
s.setValue(QStringLiteral("grid/snap"), route.snapToGrid);
s.setValue(QStringLiteral("edges/arcOnIntersection"), route.arcOnIntersection);
s.setValue(QStringLiteral("edges/arcRadius"), route.arcRadius);
s.setValue(QStringLiteral("view/colorScheme"), Theme::schemeName(scheme));
}
SettingsDialog::SettingsDialog(const EditorSettings& settings, QWidget* parent)
: QDialog(parent)
{
setWindowTitle(tr("Editor settings"));
auto* form = new QFormLayout(this);
m_gridStep = new QSpinBox;
m_gridStep->setRange(5, 200);
m_gridStep->setValue(int(settings.route.gridStep));
form->addRow(tr("Grid step, px"), m_gridStep);
m_snap = new QCheckBox(tr("Snap nodes to grid"));
m_snap->setChecked(settings.route.snapToGrid);
form->addRow(QString(), m_snap);
m_arcs = new QCheckBox(tr("Draw arcs where edges cross"));
m_arcs->setChecked(settings.route.arcOnIntersection);
form->addRow(QString(), m_arcs);
m_arcRadius = new QDoubleSpinBox;
m_arcRadius->setRange(3.0, 20.0);
m_arcRadius->setValue(settings.route.arcRadius);
form->addRow(tr("Arc radius, px"), m_arcRadius);
m_scheme = new QComboBox;
for (auto scheme : {ColorScheme::Light, ColorScheme::Dark, ColorScheme::HighContrast})
m_scheme->addItem(Theme::schemeName(scheme));
m_scheme->setCurrentText(Theme::schemeName(settings.scheme));
form->addRow(tr("Color scheme"), m_scheme);
auto* buttons =
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
form->addRow(buttons);
}
EditorSettings SettingsDialog::settings() const
{
EditorSettings s;
s.route.gridStep = m_gridStep->value();
s.route.snapToGrid = m_snap->isChecked();
s.route.arcOnIntersection = m_arcs->isChecked();
s.route.arcRadius = m_arcRadius->value();
s.scheme = Theme::schemeFromName(m_scheme->currentText());
return s;
}
} // namespace diag

39
src/app/SettingsDialog.h Normal file
View File

@ -0,0 +1,39 @@
#pragma once
#include "core/Router.h"
#include "scene/Theme.h"
#include <QDialog>
class QCheckBox;
class QComboBox;
class QDoubleSpinBox;
class QSpinBox;
namespace diag {
struct EditorSettings {
RouteConfig route;
ColorScheme scheme = ColorScheme::Light;
void load(); // from QSettings
void save() const;
};
// Editor configuration: grid step, snapping, intersection arcs, color scheme.
class SettingsDialog : public QDialog {
Q_OBJECT
public:
explicit SettingsDialog(const EditorSettings& settings, QWidget* parent = nullptr);
EditorSettings settings() const;
private:
QSpinBox* m_gridStep;
QCheckBox* m_snap;
QCheckBox* m_arcs;
QDoubleSpinBox* m_arcRadius;
QComboBox* m_scheme;
};
} // namespace diag

177
src/core/FlowSolver.cpp Normal file
View File

@ -0,0 +1,177 @@
#include "FlowSolver.h"
#include "NetworkModel.h"
#include <QtMath>
#include <QVector>
#include <functional>
#include <numeric>
namespace diag {
namespace {
// Conductance mock: wider and shorter pipes carry more flow.
double conductance(const NetworkModel& model, QUuid edgeId)
{
const double d = model.edgeProperty(edgeId, QStringLiteral("diameter")).toDouble();
const double len = model.edgeProperty(edgeId, QStringLiteral("length")).toDouble();
const double dm = d > 0 ? d : 100.0;
const double lm = len > 0 ? len : 10.0;
return qPow(dm / 100.0, 4) / lm;
}
double injection(const NetworkModel& model, QUuid nodeId)
{
const double supply = model.nodeProperty(nodeId, QStringLiteral("supply")).toDouble();
const double demand = model.nodeProperty(nodeId, QStringLiteral("demand")).toDouble();
return supply - demand;
}
// Solve A x = b in-place; returns false on a singular matrix.
bool gauss(QVector<QVector<double>>& a, QVector<double>& b, QVector<double>& x)
{
const int n = b.size();
for (int col = 0; col < n; ++col) {
int pivot = col;
for (int row = col + 1; row < n; ++row)
if (qAbs(a[row][col]) > qAbs(a[pivot][col]))
pivot = row;
if (qAbs(a[pivot][col]) < 1e-12)
return false;
a.swapItemsAt(col, pivot);
std::swap(b[col], b[pivot]);
for (int row = col + 1; row < n; ++row) {
const double f = a[row][col] / a[col][col];
if (f == 0.0)
continue;
for (int k = col; k < n; ++k)
a[row][k] -= f * a[col][k];
b[row] -= f * b[col];
}
}
x.resize(n);
for (int row = n - 1; row >= 0; --row) {
double sum = b[row];
for (int k = row + 1; k < n; ++k)
sum -= a[row][k] * x[k];
x[row] = sum / a[row][row];
}
return true;
}
} // namespace
FlowResult FlowSolver::solve(const NetworkModel& model, const QString& relationId)
{
FlowResult result;
// Edges of this relation and the nodes they touch.
QList<QUuid> edges;
QList<QUuid> nodes;
QHash<QUuid, int> nodeIndex;
for (QUuid id : model.edgeIds()) {
const Edge* e = model.edge(id);
if (!e || e->relation != relationId)
continue;
edges.append(id);
for (QUuid n : {e->fromNode, e->toNode}) {
if (!nodeIndex.contains(n)) {
nodeIndex.insert(n, nodes.size());
nodes.append(n);
}
}
}
if (edges.isEmpty()) {
result.ok = true;
result.message = QStringLiteral("No %1 edges").arg(relationId);
return result;
}
// Connected components (union-find).
QVector<int> parent(nodes.size());
for (int i = 0; i < parent.size(); ++i)
parent[i] = i;
std::function<int(int)> find = [&](int i) {
while (parent[i] != i)
i = parent[i] = parent[parent[i]];
return i;
};
for (QUuid id : edges) {
const Edge* e = model.edge(id);
parent[find(nodeIndex[e->fromNode])] = find(nodeIndex[e->toNode]);
}
// Per component: balance injections to zero-sum, then solve G*p = inj
// with the component's last node grounded (p = 0).
QHash<int, QList<int>> components; // root -> node indices
for (int i = 0; i < nodes.size(); ++i)
components[find(i)].append(i);
QHash<QUuid, double> potentials;
for (auto it = components.begin(); it != components.end(); ++it) {
const QList<int>& comp = it.value();
const int n = comp.size();
QHash<int, int> local; // global node index -> local index
for (int i = 0; i < n; ++i)
local.insert(comp.at(i), i);
QVector<double> inj(n, 0.0);
for (int i = 0; i < n; ++i)
inj[i] = injection(model, nodes.at(comp.at(i)));
const double imbalance = std::accumulate(inj.begin(), inj.end(), 0.0);
for (int i = 0; i < n; ++i)
inj[i] -= imbalance / n;
if (n < 2)
continue;
// Laplacian, reduced by grounding the last local node.
const int m = n - 1;
QVector<QVector<double>> a(m, QVector<double>(m, 0.0));
QVector<double> b(m, 0.0);
for (int i = 0; i < m; ++i)
b[i] = inj[i];
for (QUuid id : edges) {
const Edge* e = model.edge(id);
if (!local.contains(nodeIndex[e->fromNode]))
continue; // edge belongs to another component
const int u = local[nodeIndex[e->fromNode]];
const int v = local[nodeIndex[e->toNode]];
const double g = conductance(model, id);
if (u < m)
a[u][u] += g;
if (v < m)
a[v][v] += g;
if (u < m && v < m) {
a[u][v] -= g;
a[v][u] -= g;
}
}
QVector<double> p;
if (!gauss(a, b, p)) {
result.message = QStringLiteral("Singular network (parallel zero paths)");
return result;
}
for (int i = 0; i < n; ++i)
potentials.insert(nodes.at(comp.at(i)), i < m ? p[i] : 0.0);
}
for (QUuid id : edges) {
const Edge* e = model.edge(id);
const double g = conductance(model, id);
const double flow = g * (potentials.value(e->fromNode) - potentials.value(e->toNode));
result.edgeFlow.insert(id, flow);
}
result.ok = true;
return result;
}
void FlowSolver::apply(NetworkModel& model, const FlowResult& result)
{
for (auto it = result.edgeFlow.constBegin(); it != result.edgeFlow.constEnd(); ++it)
model.setEdgeFlow(it.key(), it.value());
}
} // namespace diag

29
src/core/FlowSolver.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#include "Types.h"
#include <QHash>
namespace diag {
class NetworkModel;
struct FlowResult {
bool ok = false;
QString message;
QHash<QUuid, double> edgeFlow; // signed; positive flows from -> to
};
// Mock flow-distribution calculation. Treats each relation network as a
// resistor-style network: edge conductance derives from pipe diameter and
// length, node injections from "supply"/"demand" properties. Solves nodal
// balance (Kirchhoff) per connected component with dense Gaussian
// elimination, so flow is conserved at every junction.
class FlowSolver {
public:
static FlowResult solve(const NetworkModel& model, const QString& relationId);
// Writes flows into the model (setEdgeFlow); edges of other relations keep theirs.
static void apply(NetworkModel& model, const FlowResult& result);
};
} // namespace diag

128
src/core/JsonIo.cpp Normal file
View File

@ -0,0 +1,128 @@
#include "JsonIo.h"
#include "NetworkModel.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
namespace diag {
QJsonObject JsonIo::toJson(const NetworkModel& model)
{
QJsonArray nodes;
for (QUuid id : model.nodeIds()) {
const Node* n = model.node(id);
QJsonObject o;
o.insert(QLatin1String("id"), n->id.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("type"), n->typeId);
o.insert(QLatin1String("pos"), QJsonArray{n->pos.x(), n->pos.y()});
if (!n->props.isEmpty())
o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(n->props));
nodes.append(o);
}
QJsonArray edges;
for (QUuid id : model.edgeIds()) {
const Edge* e = model.edge(id);
QJsonObject o;
o.insert(QLatin1String("id"), e->id.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("from"), e->fromNode.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("fromPort"), e->fromPort);
o.insert(QLatin1String("to"), e->toNode.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("toPort"), e->toPort);
if (!e->props.isEmpty())
o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(e->props));
edges.append(o);
}
QJsonObject root;
root.insert(QLatin1String("format"), QStringLiteral("pipediagram"));
root.insert(QLatin1String("version"), 1);
root.insert(QLatin1String("nodes"), nodes);
root.insert(QLatin1String("edges"), edges);
return root;
}
bool JsonIo::fromJson(const QJsonObject& doc, NetworkModel* model, QString* error)
{
if (doc.value(QLatin1String("format")).toString() != QLatin1String("pipediagram")) {
if (error)
*error = QStringLiteral("Not a pipediagram document");
return false;
}
model->clear();
for (const auto& nv : doc.value(QLatin1String("nodes")).toArray()) {
const QJsonObject o = nv.toObject();
const QUuid id(o.value(QLatin1String("id")).toString());
const QJsonArray pos = o.value(QLatin1String("pos")).toArray();
const QUuid added = model->addNode(o.value(QLatin1String("type")).toString(),
{pos.at(0).toDouble(), pos.at(1).toDouble()}, id);
if (added.isNull()) {
if (error)
*error = QStringLiteral("Unknown node type: %1")
.arg(o.value(QLatin1String("type")).toString());
return false;
}
const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap();
for (auto it = props.begin(); it != props.end(); ++it)
model->setNodeProperty(added, it.key(), it.value());
}
for (const auto& ev : doc.value(QLatin1String("edges")).toArray()) {
const QJsonObject o = ev.toObject();
const QUuid id(o.value(QLatin1String("id")).toString());
const QUuid added = model->addEdge(QUuid(o.value(QLatin1String("from")).toString()),
o.value(QLatin1String("fromPort")).toString(),
QUuid(o.value(QLatin1String("to")).toString()),
o.value(QLatin1String("toPort")).toString(), id);
if (added.isNull()) {
if (error)
*error = QStringLiteral("Invalid edge %1").arg(o.value(QLatin1String("id")).toString());
return false;
}
const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap();
for (auto it = props.begin(); it != props.end(); ++it)
model->setEdgeProperty(added, it.key(), it.value());
}
return true;
}
bool JsonIo::save(const NetworkModel& model, const QString& path, QString* error)
{
QSaveFile f(path);
if (!f.open(QIODevice::WriteOnly)) {
if (error)
*error = f.errorString();
return false;
}
f.write(QJsonDocument(toJson(model)).toJson(QJsonDocument::Indented));
if (!f.commit()) {
if (error)
*error = f.errorString();
return false;
}
return true;
}
bool JsonIo::load(NetworkModel* model, const QString& path, QString* error)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) {
if (error)
*error = f.errorString();
return false;
}
QJsonParseError perr;
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &perr);
if (doc.isNull()) {
if (error)
*error = perr.errorString();
return false;
}
return fromJson(doc.object(), model, error);
}
} // namespace diag

19
src/core/JsonIo.h Normal file
View File

@ -0,0 +1,19 @@
#pragma once
#include <QJsonObject>
#include <QString>
namespace diag {
class NetworkModel;
// Document persistence: nodes, edges and their property overrides as JSON.
class JsonIo {
public:
static QJsonObject toJson(const NetworkModel& model);
static bool fromJson(const QJsonObject& doc, NetworkModel* model, QString* error = nullptr);
static bool save(const NetworkModel& model, const QString& path, QString* error = nullptr);
static bool load(NetworkModel* model, const QString& path, QString* error = nullptr);
};
} // namespace diag

30
src/main.cpp Normal file
View File

@ -0,0 +1,30 @@
#include "app/MainWindow.h"
#include <QApplication>
#include <QTimer>
int main(int argc, char** argv)
{
QApplication app(argc, argv);
QCoreApplication::setOrganizationName(QStringLiteral("PipeDiagram"));
QCoreApplication::setApplicationName(QStringLiteral("PipeDiagram"));
diag::MainWindow window;
window.show();
const QStringList args = app.arguments();
if (args.contains(QStringLiteral("--sample")))
window.loadSample();
if (args.contains(QStringLiteral("--simulate")))
window.setSimulationRunning(true);
// Headless smoke check: --screenshot <file.png> renders and exits.
const int shotIdx = args.indexOf(QStringLiteral("--screenshot"));
if (shotIdx >= 0 && shotIdx + 1 < args.size()) {
const QString path = args.at(shotIdx + 1);
QTimer::singleShot(300, &window, [&window, path] {
window.grab().save(path);
QApplication::quit();
});
}
return app.exec();
}

276
src/scene/Commands.cpp Normal file
View File

@ -0,0 +1,276 @@
#include "Commands.h"
#include "core/NetworkModel.h"
#include <QJsonArray>
namespace diag {
namespace {
QJsonObject nodeToJson(const Node& n)
{
QJsonObject o;
o.insert(QLatin1String("id"), n.id.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("type"), n.typeId);
o.insert(QLatin1String("pos"), QJsonArray{n.pos.x(), n.pos.y()});
o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(n.props));
return o;
}
QJsonObject edgeToJson(const Edge& e)
{
QJsonObject o;
o.insert(QLatin1String("id"), e.id.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("from"), e.fromNode.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("fromPort"), e.fromPort);
o.insert(QLatin1String("to"), e.toNode.toString(QUuid::WithoutBraces));
o.insert(QLatin1String("toPort"), e.toPort);
o.insert(QLatin1String("props"), QJsonObject::fromVariantMap(e.props));
return o;
}
void restoreNodes(NetworkModel* model, const QJsonArray& nodes)
{
for (const auto& nv : nodes) {
const QJsonObject o = nv.toObject();
const QJsonArray pos = o.value(QLatin1String("pos")).toArray();
const QUuid id = model->addNode(o.value(QLatin1String("type")).toString(),
{pos.at(0).toDouble(), pos.at(1).toDouble()},
QUuid(o.value(QLatin1String("id")).toString()));
const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap();
for (auto it = props.begin(); it != props.end(); ++it)
model->setNodeProperty(id, it.key(), it.value());
}
}
void restoreEdges(NetworkModel* model, const QJsonArray& edges)
{
for (const auto& ev : edges) {
const QJsonObject o = ev.toObject();
const QUuid id = model->addEdge(QUuid(o.value(QLatin1String("from")).toString()),
o.value(QLatin1String("fromPort")).toString(),
QUuid(o.value(QLatin1String("to")).toString()),
o.value(QLatin1String("toPort")).toString(),
QUuid(o.value(QLatin1String("id")).toString()));
const QVariantMap props = o.value(QLatin1String("props")).toObject().toVariantMap();
for (auto it = props.begin(); it != props.end(); ++it)
model->setEdgeProperty(id, it.key(), it.value());
}
}
} // namespace
QJsonObject fragmentFromSelection(const NetworkModel& model, const QList<QUuid>& nodes,
const QList<QUuid>& edges)
{
QJsonArray nodeArr;
for (QUuid id : nodes)
if (const Node* n = model.node(id))
nodeArr.append(nodeToJson(*n));
// Edges: explicitly selected ones plus edges fully inside the node set.
QList<QUuid> edgeIds = edges;
for (QUuid nid : nodes) {
for (QUuid eid : model.edgesOfNode(nid)) {
const Edge* e = model.edge(eid);
if (nodes.contains(e->fromNode) && nodes.contains(e->toNode)
&& !edgeIds.contains(eid))
edgeIds.append(eid);
}
}
QJsonArray edgeArr;
for (QUuid id : edgeIds)
if (const Edge* e = model.edge(id))
edgeArr.append(edgeToJson(*e));
QJsonObject frag;
frag.insert(QLatin1String("nodes"), nodeArr);
frag.insert(QLatin1String("edges"), edgeArr);
return frag;
}
// --- AddNodeCommand ---
AddNodeCommand::AddNodeCommand(NetworkModel* model, const QString& typeId, QPointF pos,
QUndoCommand* parent)
: QUndoCommand(QObject::tr("Add node"), parent), m_model(model), m_typeId(typeId), m_pos(pos)
{
}
void AddNodeCommand::redo()
{
m_id = m_model->addNode(m_typeId, m_pos, m_id);
}
void AddNodeCommand::undo()
{
m_model->removeNode(m_id);
}
// --- AddEdgeCommand ---
AddEdgeCommand::AddEdgeCommand(NetworkModel* model, QUuid fromNode, const QString& fromPort,
QUuid toNode, const QString& toPort, QUndoCommand* parent)
: QUndoCommand(QObject::tr("Connect"), parent)
, m_model(model)
, m_fromNode(fromNode)
, m_fromPort(fromPort)
, m_toNode(toNode)
, m_toPort(toPort)
{
}
void AddEdgeCommand::redo()
{
m_id = m_model->addEdge(m_fromNode, m_fromPort, m_toNode, m_toPort, m_id);
}
void AddEdgeCommand::undo()
{
m_model->removeEdge(m_id);
}
// --- MoveNodesCommand ---
MoveNodesCommand::MoveNodesCommand(NetworkModel* model, const QList<Move>& moves,
QUndoCommand* parent)
: QUndoCommand(QObject::tr("Move"), parent), m_model(model), m_moves(moves)
{
}
void MoveNodesCommand::redo()
{
for (const auto& m : m_moves)
m_model->moveNode(m.id, m.to);
}
void MoveNodesCommand::undo()
{
for (const auto& m : m_moves)
m_model->moveNode(m.id, m.from);
}
// --- RemoveItemsCommand ---
RemoveItemsCommand::RemoveItemsCommand(NetworkModel* model, const QList<QUuid>& nodes,
const QList<QUuid>& edges, QUndoCommand* parent)
: QUndoCommand(QObject::tr("Delete"), parent), m_model(model), m_nodes(nodes)
{
// Capture full state now: attached edges disappear with their nodes.
QList<QUuid> allEdges = edges;
for (QUuid nid : nodes)
for (QUuid eid : model->edgesOfNode(nid))
if (!allEdges.contains(eid))
allEdges.append(eid);
const QJsonObject frag = fragmentFromSelection(*model, nodes, allEdges);
m_nodeJson = frag.value(QLatin1String("nodes")).toArray();
QJsonArray edgeArr;
for (QUuid id : allEdges)
if (model->edge(id)) {
for (const auto& ev : frag.value(QLatin1String("edges")).toArray())
if (QUuid(ev.toObject().value(QLatin1String("id")).toString()) == id)
edgeArr.append(ev);
}
m_edgeJson = edgeArr;
}
void RemoveItemsCommand::redo()
{
for (const auto& ev : m_edgeJson)
m_model->removeEdge(QUuid(ev.toObject().value(QLatin1String("id")).toString()));
for (QUuid id : m_nodes)
m_model->removeNode(id);
}
void RemoveItemsCommand::undo()
{
restoreNodes(m_model, m_nodeJson);
restoreEdges(m_model, m_edgeJson);
}
// --- SetPropertyCommand ---
SetPropertyCommand::SetPropertyCommand(NetworkModel* model, Target target, QUuid id,
const QString& key, const QVariant& value,
QUndoCommand* parent)
: QUndoCommand(QObject::tr("Change %1").arg(key), parent)
, m_model(model)
, m_target(target)
, m_id(id)
, m_key(key)
, m_new(value)
{
m_old = target == NodeTarget ? model->nodeProperty(id, key) : model->edgeProperty(id, key);
}
void SetPropertyCommand::redo()
{
if (m_target == NodeTarget)
m_model->setNodeProperty(m_id, m_key, m_new);
else
m_model->setEdgeProperty(m_id, m_key, m_new);
}
void SetPropertyCommand::undo()
{
if (m_target == NodeTarget)
m_model->setNodeProperty(m_id, m_key, m_old);
else
m_model->setEdgeProperty(m_id, m_key, m_old);
}
// --- PasteCommand ---
PasteCommand::PasteCommand(NetworkModel* model, const QJsonObject& fragment, QPointF offset,
QUndoCommand* parent)
: QUndoCommand(QObject::tr("Paste"), parent), m_model(model)
{
// Remap ids once so repeated redo() recreates identical objects.
QHash<QString, QString> idMap;
QJsonArray nodes;
for (const auto& nv : fragment.value(QLatin1String("nodes")).toArray()) {
QJsonObject o = nv.toObject();
const QString oldId = o.value(QLatin1String("id")).toString();
const QString newId = QUuid::createUuid().toString(QUuid::WithoutBraces);
idMap.insert(oldId, newId);
o.insert(QLatin1String("id"), newId);
const QJsonArray pos = o.value(QLatin1String("pos")).toArray();
o.insert(QLatin1String("pos"), QJsonArray{pos.at(0).toDouble() + offset.x(),
pos.at(1).toDouble() + offset.y()});
nodes.append(o);
m_nodeIds.append(QUuid(newId));
}
QJsonArray edges;
for (const auto& ev : fragment.value(QLatin1String("edges")).toArray()) {
QJsonObject o = ev.toObject();
const QString from = o.value(QLatin1String("from")).toString();
const QString to = o.value(QLatin1String("to")).toString();
if (!idMap.contains(from) || !idMap.contains(to))
continue;
const QString newId = QUuid::createUuid().toString(QUuid::WithoutBraces);
o.insert(QLatin1String("id"), newId);
o.insert(QLatin1String("from"), idMap.value(from));
o.insert(QLatin1String("to"), idMap.value(to));
edges.append(o);
m_edgeIds.append(QUuid(newId));
}
m_fragment.insert(QLatin1String("nodes"), nodes);
m_fragment.insert(QLatin1String("edges"), edges);
}
void PasteCommand::redo()
{
restoreNodes(m_model, m_fragment.value(QLatin1String("nodes")).toArray());
restoreEdges(m_model, m_fragment.value(QLatin1String("edges")).toArray());
}
void PasteCommand::undo()
{
for (QUuid id : m_edgeIds)
m_model->removeEdge(id);
for (QUuid id : m_nodeIds)
m_model->removeNode(id);
}
} // namespace diag

115
src/scene/Commands.h Normal file
View File

@ -0,0 +1,115 @@
#pragma once
#include "core/Types.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QUndoCommand>
namespace diag {
class NetworkModel;
class AddNodeCommand : public QUndoCommand {
public:
AddNodeCommand(NetworkModel* model, const QString& typeId, QPointF pos,
QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
QUuid nodeId() const { return m_id; }
private:
NetworkModel* m_model;
QString m_typeId;
QPointF m_pos;
QUuid m_id;
};
class AddEdgeCommand : public QUndoCommand {
public:
AddEdgeCommand(NetworkModel* model, QUuid fromNode, const QString& fromPort, QUuid toNode,
const QString& toPort, QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
QUuid edgeId() const { return m_id; }
private:
NetworkModel* m_model;
QUuid m_fromNode;
QString m_fromPort;
QUuid m_toNode;
QString m_toPort;
QUuid m_id;
};
class MoveNodesCommand : public QUndoCommand {
public:
struct Move {
QUuid id;
QPointF from;
QPointF to;
};
MoveNodesCommand(NetworkModel* model, const QList<Move>& moves,
QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
private:
NetworkModel* m_model;
QList<Move> m_moves;
};
// Removes nodes (with attached edges) and explicitly selected edges;
// undo restores everything with original ids and properties.
class RemoveItemsCommand : public QUndoCommand {
public:
RemoveItemsCommand(NetworkModel* model, const QList<QUuid>& nodes, const QList<QUuid>& edges,
QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
private:
NetworkModel* m_model;
QList<QUuid> m_nodes;
QJsonArray m_nodeJson;
QJsonArray m_edgeJson; // all removed edges (attached + selected)
};
class SetPropertyCommand : public QUndoCommand {
public:
enum Target { NodeTarget, EdgeTarget };
SetPropertyCommand(NetworkModel* model, Target target, QUuid id, const QString& key,
const QVariant& value, QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
private:
NetworkModel* m_model;
Target m_target;
QUuid m_id;
QString m_key;
QVariant m_old;
QVariant m_new;
};
// Inserts a JSON fragment (from copy) with fresh ids at an offset.
class PasteCommand : public QUndoCommand {
public:
PasteCommand(NetworkModel* model, const QJsonObject& fragment, QPointF offset,
QUndoCommand* parent = nullptr);
void redo() override;
void undo() override;
QList<QUuid> pastedNodes() const { return m_nodeIds; }
private:
NetworkModel* m_model;
QJsonObject m_fragment; // ids already remapped to fresh ones
QList<QUuid> m_nodeIds;
QList<QUuid> m_edgeIds;
};
// Serialize a node/edge subset for clipboard or removal restore.
QJsonObject fragmentFromSelection(const NetworkModel& model, const QList<QUuid>& nodes,
const QList<QUuid>& edges);
} // namespace diag

448
src/scene/DiagramScene.cpp Normal file
View File

@ -0,0 +1,448 @@
#include "DiagramScene.h"
#include "Commands.h"
#include "EdgeItem.h"
#include "NodeItem.h"
#include <QClipboard>
#include <QGraphicsPathItem>
#include <QGraphicsView>
#include <QLineF>
#include <QGraphicsSceneMouseEvent>
#include <QGuiApplication>
#include <QJsonDocument>
#include <QKeyEvent>
#include <QMimeData>
#include <QSvgRenderer>
#include <QTimer>
namespace diag {
namespace {
const char* kClipboardMime = "application/x-pipediagram-fragment";
}
DiagramScene::DiagramScene(NetworkModel* model, QUndoStack* undoStack, QObject* parent)
: QGraphicsScene(parent), m_model(model), m_undo(undoStack)
{
setSceneRect(-2000, -2000, 6000, 6000);
connect(m_model, &NetworkModel::nodeAdded, this, &DiagramScene::onNodeAdded);
connect(m_model, &NetworkModel::nodeRemoved, this, &DiagramScene::onNodeRemoved);
connect(m_model, &NetworkModel::nodeMoved, this, &DiagramScene::onNodeMoved);
connect(m_model, &NetworkModel::nodeChanged, this, [this](QUuid id) {
if (NodeItem* item = m_nodeItems.value(id))
item->update();
});
connect(m_model, &NetworkModel::edgeAdded, this, &DiagramScene::onEdgeAdded);
connect(m_model, &NetworkModel::edgeRemoved, this, &DiagramScene::onEdgeRemoved);
connect(m_model, &NetworkModel::edgeChanged, this, [this](QUuid id) {
if (EdgeItem* item = m_edgeItems.value(id))
item->update();
});
connect(m_model, &NetworkModel::flowChanged, this, [this](QUuid) {
recomputeMaxFlow();
for (auto* e : std::as_const(m_edgeItems))
e->update();
});
connect(m_model, &NetworkModel::modelReset, this, &DiagramScene::rebuildAll);
m_animTimer = new QTimer(this);
m_animTimer->setInterval(33);
connect(m_animTimer, &QTimer::timeout, this, [this] {
m_animPhase += 1.0;
for (auto* e : std::as_const(m_edgeItems))
e->update();
});
rebuildAll();
}
void DiagramScene::setTheme(const Theme& theme)
{
m_theme = theme;
for (auto* v : views())
v->viewport()->update();
update();
for (auto* n : std::as_const(m_nodeItems))
n->update();
for (auto* e : std::as_const(m_edgeItems))
e->update();
}
void DiagramScene::setRouteConfig(const RouteConfig& cfg)
{
m_routeConfig = cfg;
rerouteAll();
for (auto* v : views())
v->viewport()->update();
}
QSvgRenderer* DiagramScene::rendererFor(const QString& typeId)
{
if (m_renderers.contains(typeId))
return m_renderers.value(typeId);
const NodeType* type = m_model->registry()->nodeType(typeId);
QSvgRenderer* renderer = nullptr;
if (type && !type->svgPath.isEmpty())
renderer = new QSvgRenderer(type->svgPath, this);
m_renderers.insert(typeId, renderer);
return renderer;
}
// --- model sync ---
void DiagramScene::onNodeAdded(QUuid id)
{
auto* item = new NodeItem(this, id);
addItem(item);
m_nodeItems.insert(id, item);
}
void DiagramScene::onNodeRemoved(QUuid id)
{
if (NodeItem* item = m_nodeItems.take(id)) {
removeItem(item);
delete item;
}
}
void DiagramScene::onNodeMoved(QUuid id)
{
if (NodeItem* item = m_nodeItems.value(id))
item->syncFromModel();
// Re-route the moved node's edges; crossings can change anywhere, so
// recompute hops globally (cheap at typical diagram sizes).
rerouteAll();
}
void DiagramScene::onEdgeAdded(QUuid id)
{
auto* item = new EdgeItem(this, id);
addItem(item);
m_edgeItems.insert(id, item);
rerouteAll();
}
void DiagramScene::onEdgeRemoved(QUuid id)
{
if (EdgeItem* item = m_edgeItems.take(id)) {
removeItem(item);
delete item;
}
rerouteAll();
}
void DiagramScene::rebuildAll()
{
cancelConnection();
for (auto* item : std::as_const(m_edgeItems)) {
removeItem(item);
delete item;
}
m_edgeItems.clear();
for (auto* item : std::as_const(m_nodeItems)) {
removeItem(item);
delete item;
}
m_nodeItems.clear();
for (QUuid id : m_model->nodeIds())
onNodeAdded(id);
for (QUuid id : m_model->edgeIds()) {
auto* item = new EdgeItem(this, id);
addItem(item);
m_edgeItems.insert(id, item);
}
rerouteAll();
recomputeMaxFlow();
}
void DiagramScene::rerouteAll()
{
const QList<QUuid> order = m_model->edgeIds();
QList<QList<QPointF>> polys;
polys.reserve(order.size());
for (QUuid id : order) {
const Edge* e = m_model->edge(id);
const NodeType* fromType = m_model->nodeTypeOf(e->fromNode);
const NodeType* toType = m_model->nodeTypeOf(e->toNode);
const PortSpec* fromPort = fromType ? fromType->port(e->fromPort) : nullptr;
const PortSpec* toPort = toType ? toType->port(e->toPort) : nullptr;
const QPointF a = m_model->portScenePos(e->fromNode, e->fromPort);
const QPointF b = m_model->portScenePos(e->toNode, e->toPort);
polys.append(Router::route(a, fromPort ? fromPort->side : Side::Right, b,
toPort ? toPort->side : Side::Left,
m_routeConfig.gridStep));
}
for (int i = 0; i < order.size(); ++i) {
EdgeItem* item = m_edgeItems.value(order.at(i));
if (!item)
continue;
// Later edges jump over earlier ones.
QList<QPointF> hops;
for (int j = 0; j < i; ++j)
hops.append(Router::crossings(polys.at(i), polys.at(j)));
item->setRoute(polys.at(i), hops);
}
}
void DiagramScene::recomputeMaxFlow()
{
m_maxAbsFlow = 0.0;
for (QUuid id : m_model->edgeIds())
m_maxAbsFlow = qMax(m_maxAbsFlow, qAbs(m_model->edge(id)->flowRate));
}
// --- connection drag ---
void DiagramScene::startConnection(PortItem* port)
{
cancelConnection();
m_connectSource = port;
m_connectPreview = new QGraphicsPathItem();
QPen pen(m_theme.selection, 2, Qt::DashLine);
m_connectPreview->setPen(pen);
m_connectPreview->setZValue(3);
addItem(m_connectPreview);
for (auto* n : std::as_const(m_nodeItems))
n->updatePortVisibility();
emit connectionMessage(tr("Drag to a highlighted port to connect; Esc cancels"));
}
PortItem::ConnectState DiagramScene::connectStateFor(const PortItem* port) const
{
if (!m_connectSource || port == m_connectSource)
return PortItem::ConnectState::Normal;
const bool ok = m_model->canConnect(m_connectSource->nodeItem()->nodeId(),
m_connectSource->spec().id, port->nodeItem()->nodeId(),
port->spec().id);
return ok ? PortItem::ConnectState::Compatible : PortItem::ConnectState::Incompatible;
}
void DiagramScene::updateConnectionDrag(QPointF scenePos)
{
if (!m_connectSource || !m_connectPreview)
return;
const QPointF start = m_connectSource->scenePos();
const Side startSide = m_connectSource->spec().side;
Side endSide;
const QPointF d = scenePos - start;
if (qAbs(d.x()) >= qAbs(d.y()))
endSide = d.x() >= 0 ? Side::Left : Side::Right;
else
endSide = d.y() >= 0 ? Side::Top : Side::Bottom;
// Snap the preview end to a compatible port when hovering one.
QPointF end = scenePos;
if (PortItem* target = portAt(scenePos);
target && connectStateFor(target) == PortItem::ConnectState::Compatible) {
end = target->scenePos();
endSide = target->spec().side;
}
const auto poly = Router::route(start, startSide, end, endSide, m_routeConfig.gridStep);
m_connectPreview->setPath(Router::toPath(poly, {}, m_routeConfig.arcRadius));
}
PortItem* DiagramScene::portAt(QPointF scenePos) const
{
// Tolerant hit test: ports are small, accept hits within a radius.
const QRectF probe(scenePos.x() - 10, scenePos.y() - 10, 20, 20);
PortItem* best = nullptr;
double bestDist = 1e18;
for (QGraphicsItem* item : items(probe, Qt::IntersectsItemBoundingRect)) {
if (auto* port = dynamic_cast<PortItem*>(item)) {
const double dist = QLineF(port->scenePos(), scenePos).length();
if (dist < bestDist) {
bestDist = dist;
best = port;
}
}
}
return best;
}
bool DiagramScene::finishConnection(QPointF scenePos)
{
if (!m_connectSource)
return false;
PortItem* source = m_connectSource;
PortItem* target = portAt(scenePos);
bool ok = false;
if (target && target != source) {
QString reason;
if (m_model->canConnect(source->nodeItem()->nodeId(), source->spec().id,
target->nodeItem()->nodeId(), target->spec().id, &reason)) {
m_undo->push(new AddEdgeCommand(m_model, source->nodeItem()->nodeId(),
source->spec().id, target->nodeItem()->nodeId(),
target->spec().id));
emit connectionMessage(tr("Connected"));
ok = true;
} else {
emit connectionMessage(reason);
}
} else {
emit connectionMessage(QString());
}
cancelConnection();
return ok;
}
void DiagramScene::cancelConnection()
{
m_connectSource = nullptr;
if (m_connectPreview) {
removeItem(m_connectPreview);
delete m_connectPreview;
m_connectPreview = nullptr;
}
for (auto* n : std::as_const(m_nodeItems))
n->updatePortVisibility();
}
// --- editing operations ---
QUuid DiagramScene::dropNode(const QString& typeId, QPointF scenePos)
{
const NodeType* type = m_model->registry()->nodeType(typeId);
if (!type)
return {};
QPointF pos = scenePos - QPointF(type->size.width() / 2, type->size.height() / 2);
if (snapEnabled())
pos = Router::snapPoint(pos, m_routeConfig.gridStep);
auto* cmd = new AddNodeCommand(m_model, typeId, pos);
m_undo->push(cmd);
return cmd->nodeId();
}
void DiagramScene::deleteSelection()
{
QList<QUuid> nodes;
QList<QUuid> edges;
for (QGraphicsItem* item : selectedItems()) {
if (auto* n = dynamic_cast<NodeItem*>(item))
nodes.append(n->nodeId());
else if (auto* e = dynamic_cast<EdgeItem*>(item))
edges.append(e->edgeId());
}
if (nodes.isEmpty() && edges.isEmpty())
return;
m_undo->push(new RemoveItemsCommand(m_model, nodes, edges));
}
void DiagramScene::copySelection()
{
QList<QUuid> nodes;
QList<QUuid> edges;
for (QGraphicsItem* item : selectedItems()) {
if (auto* n = dynamic_cast<NodeItem*>(item))
nodes.append(n->nodeId());
else if (auto* e = dynamic_cast<EdgeItem*>(item))
edges.append(e->edgeId());
}
if (nodes.isEmpty())
return;
const QJsonObject frag = fragmentFromSelection(*m_model, nodes, edges);
auto* mime = new QMimeData();
mime->setData(QLatin1String(kClipboardMime), QJsonDocument(frag).toJson());
QGuiApplication::clipboard()->setMimeData(mime);
}
void DiagramScene::cutSelection()
{
copySelection();
deleteSelection();
}
bool DiagramScene::canPaste() const
{
const QMimeData* mime = QGuiApplication::clipboard()->mimeData();
return mime && mime->hasFormat(QLatin1String(kClipboardMime));
}
void DiagramScene::paste()
{
if (!canPaste())
return;
const QByteArray data =
QGuiApplication::clipboard()->mimeData()->data(QLatin1String(kClipboardMime));
const QJsonObject frag = QJsonDocument::fromJson(data).object();
const double step = m_routeConfig.gridStep;
auto* cmd = new PasteCommand(m_model, frag, {step, step});
m_undo->push(cmd);
clearSelection();
for (QUuid id : cmd->pastedNodes())
if (NodeItem* item = m_nodeItems.value(id))
item->setSelected(true);
}
void DiagramScene::selectAll()
{
for (auto* n : std::as_const(m_nodeItems))
n->setSelected(true);
for (auto* e : std::as_const(m_edgeItems))
e->setSelected(true);
}
// --- node dragging ---
void DiagramScene::captureMoveOrigin()
{
m_moveOrigin.clear();
for (QGraphicsItem* item : selectedItems())
if (auto* n = dynamic_cast<NodeItem*>(item))
m_moveOrigin.insert(n->nodeId(), n->pos());
}
void DiagramScene::commitMove()
{
QList<MoveNodesCommand::Move> moves;
for (auto it = m_moveOrigin.constBegin(); it != m_moveOrigin.constEnd(); ++it) {
const Node* n = m_model->node(it.key());
if (n && n->pos != it.value())
moves.append({it.key(), it.value(), n->pos});
}
m_moveOrigin.clear();
if (moves.isEmpty())
return;
// Positions are already applied by the live drag; pushing the command
// re-applies them (no-op) and records the undo state.
m_undo->push(new MoveNodesCommand(m_model, moves));
}
// --- flow animation ---
void DiagramScene::setFlowAnimationEnabled(bool enabled)
{
if (m_animating == enabled)
return;
m_animating = enabled;
if (enabled)
m_animTimer->start();
else
m_animTimer->stop();
for (auto* e : std::as_const(m_edgeItems))
e->update();
}
// --- events ---
void DiagramScene::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
{
if (isConnecting())
updateConnectionDrag(event->scenePos());
QGraphicsScene::mouseMoveEvent(event);
}
void DiagramScene::keyPressEvent(QKeyEvent* event)
{
if (event->key() == Qt::Key_Escape && isConnecting()) {
cancelConnection();
emit connectionMessage(QString());
event->accept();
return;
}
QGraphicsScene::keyPressEvent(event);
}
} // namespace diag

108
src/scene/DiagramScene.h Normal file
View File

@ -0,0 +1,108 @@
#pragma once
#include "PortItem.h"
#include "Theme.h"
#include "core/NetworkModel.h"
#include "core/Router.h"
#include <QGraphicsScene>
#include <QHash>
#include <QUndoStack>
class QGraphicsPathItem;
class QSvgRenderer;
class QTimer;
namespace diag {
class EdgeItem;
class NodeItem;
// Observes the NetworkModel and keeps graphics items in sync. Hosts the
// interactive connection drag, snapping/routing configuration, theme, undoable
// editing operations and the flow animation clock.
class DiagramScene : public QGraphicsScene {
Q_OBJECT
public:
explicit DiagramScene(NetworkModel* model, QUndoStack* undoStack, QObject* parent = nullptr);
NetworkModel* model() const { return m_model; }
QUndoStack* undoStack() const { return m_undo; }
const Theme& theme() const { return m_theme; }
void setTheme(const Theme& theme);
const RouteConfig& routeConfig() const { return m_routeConfig; }
void setRouteConfig(const RouteConfig& cfg);
bool snapEnabled() const { return m_routeConfig.snapToGrid; }
NodeItem* nodeItem(QUuid id) const { return m_nodeItems.value(id); }
EdgeItem* edgeItem(QUuid id) const { return m_edgeItems.value(id); }
QSvgRenderer* rendererFor(const QString& typeId);
// --- interactive connection drag (started from a PortItem) ---
void startConnection(PortItem* port);
void updateConnectionDrag(QPointF scenePos);
bool finishConnection(QPointF scenePos);
void cancelConnection();
bool isConnecting() const { return m_connectSource != nullptr; }
PortItem::ConnectState connectStateFor(const PortItem* port) const;
// --- editing operations (all undoable) ---
QUuid dropNode(const QString& typeId, QPointF scenePos);
void deleteSelection();
void copySelection();
void cutSelection();
void paste();
bool canPaste() const;
void selectAll();
// Node-drag bookkeeping (called by NodeItem).
void captureMoveOrigin();
void commitMove();
// --- flow animation ---
void setFlowAnimationEnabled(bool enabled);
bool flowAnimationEnabled() const { return m_animating; }
double animationPhase() const { return m_animPhase; }
double maxAbsFlow() const { return m_maxAbsFlow; }
signals:
void connectionMessage(const QString& text); // status-bar feedback
protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
private:
void onNodeAdded(QUuid id);
void onNodeRemoved(QUuid id);
void onNodeMoved(QUuid id);
void onEdgeAdded(QUuid id);
void onEdgeRemoved(QUuid id);
void rebuildAll();
void rerouteAll();
void recomputeMaxFlow();
PortItem* portAt(QPointF scenePos) const;
NetworkModel* m_model;
QUndoStack* m_undo;
Theme m_theme = Theme::make(ColorScheme::Light);
RouteConfig m_routeConfig;
QHash<QUuid, NodeItem*> m_nodeItems;
QHash<QUuid, EdgeItem*> m_edgeItems;
QHash<QString, QSvgRenderer*> m_renderers;
PortItem* m_connectSource = nullptr;
QGraphicsPathItem* m_connectPreview = nullptr;
QHash<QUuid, QPointF> m_moveOrigin;
QTimer* m_animTimer;
bool m_animating = false;
double m_animPhase = 0.0;
double m_maxAbsFlow = 0.0;
};
} // namespace diag

167
src/scene/EdgeItem.cpp Normal file
View File

@ -0,0 +1,167 @@
#include "EdgeItem.h"
#include "DiagramScene.h"
#include "core/NetworkModel.h"
#include "core/Router.h"
#include <QPainter>
#include <QPainterPathStroker>
#include <QtMath>
namespace diag {
EdgeItem::EdgeItem(DiagramScene* scene, QUuid edgeId) : m_scene(scene), m_id(edgeId)
{
setFlag(ItemIsSelectable);
setZValue(0);
setAcceptHoverEvents(true);
}
NetworkModel* EdgeItem::model() const
{
return m_scene->model();
}
void EdgeItem::setRoute(const QList<QPointF>& poly, const QList<QPointF>& hops)
{
prepareGeometryChange();
m_poly = poly;
m_hops = hops;
const RouteConfig& cfg = m_scene->routeConfig();
m_path = Router::toPath(poly, cfg.arcOnIntersection ? hops : QList<QPointF>{},
cfg.arcRadius);
update();
}
QRectF EdgeItem::boundingRect() const
{
const double m = 14;
return m_path.boundingRect().adjusted(-m, -m, m, m);
}
QPainterPath EdgeItem::shape() const
{
QPainterPathStroker stroker;
stroker.setWidth(qMax(10.0, basePen().widthF() + 6));
return stroker.createStroke(m_path);
}
QPen EdgeItem::basePen() const
{
const Theme& theme = m_scene->theme();
const Edge* e = model()->edge(m_id);
QColor color;
const QString colorName = model()->edgeProperty(m_id, QStringLiteral("color")).toString();
if (QColor::isValidColorName(colorName))
color = QColor(colorName);
else
color = theme.relationColor(e ? model()->registry()->relation(e->relation) : nullptr);
const double width = model()->edgeProperty(m_id, QStringLiteral("lineWidth")).toDouble();
const QString style = model()->edgeProperty(m_id, QStringLiteral("lineStyle")).toString();
Qt::PenStyle penStyle = Qt::SolidLine;
if (style == QLatin1String("Dashed"))
penStyle = Qt::DashLine;
else if (style == QLatin1String("Dotted"))
penStyle = Qt::DotLine;
else if (style == QLatin1String("DashDot"))
penStyle = Qt::DashDotLine;
QPen pen(color, qMax(0.5, width), penStyle, Qt::FlatCap, Qt::MiterJoin);
return pen;
}
void EdgeItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*)
{
if (m_poly.size() < 2)
return;
painter->setRenderHint(QPainter::Antialiasing);
const Theme& theme = m_scene->theme();
const QPen pen = basePen();
if (isSelected()) {
QColor halo = theme.selection;
halo.setAlpha(90);
painter->setPen(QPen(halo, pen.widthF() + 6, Qt::SolidLine, Qt::RoundCap));
painter->setBrush(Qt::NoBrush);
painter->drawPath(m_path);
}
painter->setPen(pen);
painter->setBrush(Qt::NoBrush);
painter->drawPath(m_path);
// End decorations sit on the raw polyline ends (arcs never reach ends).
const QString head = model()->edgeProperty(m_id, QStringLiteral("headDecoration")).toString();
const QString tail = model()->edgeProperty(m_id, QStringLiteral("tailDecoration")).toString();
drawDecoration(painter, head, m_poly.last(), m_poly.at(m_poly.size() - 2), pen.color(),
pen.widthF());
drawDecoration(painter, tail, m_poly.first(), m_poly.at(1), pen.color(), pen.widthF());
if (m_scene->flowAnimationEnabled())
drawFlowOverlay(painter, m_path, pen.widthF());
const QString title = model()->edgeProperty(m_id, QStringLiteral("title")).toString();
if (!title.isEmpty()) {
const QPointF mid = Router::pointAt(m_poly, 0.5);
painter->setPen(theme.label);
QFont f = painter->font();
f.setPointSizeF(8.0);
painter->setFont(f);
painter->drawText(QRectF(mid.x() - 60, mid.y() - 18, 120, 14), Qt::AlignCenter, title);
}
}
void EdgeItem::drawDecoration(QPainter* painter, const QString& kind, QPointF tip, QPointF back,
const QColor& color, double width) const
{
if (kind.isEmpty() || kind == QLatin1String("None"))
return;
QLineF dir(back, tip);
if (dir.length() < 1e-6)
return;
dir.setLength(1.0);
const QPointF u = dir.p2() - dir.p1(); // unit vector toward the tip
const QPointF n(-u.y(), u.x()); // unit normal
const double s = qMax(8.0, width * 3.5); // decoration size
painter->save();
painter->setPen(QPen(color, qMax(1.0, width * 0.8)));
painter->setBrush(color);
if (kind == QLatin1String("Arrow")) {
QPolygonF poly{tip, tip - u * s + n * s * 0.45, tip - u * s - n * s * 0.45};
painter->drawPolygon(poly);
} else if (kind == QLatin1String("Circle")) {
painter->drawEllipse(tip - u * s * 0.5, s * 0.5, s * 0.5);
} else if (kind == QLatin1String("Diamond")) {
const QPointF c = tip - u * s * 0.5;
QPolygonF poly{tip, c + n * s * 0.4, tip - u * s, c - n * s * 0.4};
painter->drawPolygon(poly);
} else if (kind == QLatin1String("Bar")) {
painter->setPen(QPen(color, qMax(1.5, width)));
painter->drawLine(tip + n * s * 0.5, tip - n * s * 0.5);
}
painter->restore();
}
void EdgeItem::drawFlowOverlay(QPainter* painter, const QPainterPath& path, double width) const
{
const Edge* e = model()->edge(m_id);
if (!e || qAbs(e->flowRate) < 1e-9)
return;
const double maxFlow = qMax(1e-9, m_scene->maxAbsFlow());
const double ratio = qBound(0.15, qAbs(e->flowRate) / maxFlow, 1.0);
// Moving dashes: speed and thickness encode volume, offset sign encodes
// direction (positive flow runs from tail to head).
QPen pen(m_scene->theme().flowOverlay, qMax(1.0, width * (0.25 + 0.45 * ratio)),
Qt::CustomDashLine, Qt::FlatCap);
pen.setDashPattern({1.2, 3.2});
const double dir = e->flowRate > 0 ? -1.0 : 1.0;
pen.setDashOffset(dir * m_scene->animationPhase() * (0.4 + 1.6 * ratio));
painter->setPen(pen);
painter->setBrush(Qt::NoBrush);
painter->drawPath(path);
}
} // namespace diag

44
src/scene/EdgeItem.h Normal file
View File

@ -0,0 +1,44 @@
#pragma once
#include "core/Types.h"
#include <QGraphicsObject>
namespace diag {
class DiagramScene;
class NetworkModel;
// An edge (pipe/cable) routed orthogonally between two ports. Supports line
// styles, head/tail decorations (arrow, circle, diamond, bar) and an animated
// flow overlay whose direction and density follow the solver result.
class EdgeItem : public QGraphicsObject {
Q_OBJECT
public:
EdgeItem(DiagramScene* scene, QUuid edgeId);
QUuid edgeId() const { return m_id; }
NetworkModel* model() const;
const QList<QPointF>& polyline() const { return m_poly; }
void setRoute(const QList<QPointF>& poly, const QList<QPointF>& hops);
QRectF boundingRect() const override;
QPainterPath shape() const override;
void paint(QPainter* painter, const QStyleOptionGraphicsItem* option,
QWidget* widget) override;
private:
QPen basePen() const;
void drawDecoration(QPainter* painter, const QString& kind, QPointF tip, QPointF back,
const QColor& color, double width) const;
void drawFlowOverlay(QPainter* painter, const QPainterPath& path, double width) const;
DiagramScene* m_scene;
QUuid m_id;
QList<QPointF> m_poly;
QList<QPointF> m_hops;
QPainterPath m_path;
};
} // namespace diag

188
src/scene/GridView.cpp Normal file
View File

@ -0,0 +1,188 @@
#include "GridView.h"
#include "DiagramScene.h"
#include <QContextMenuEvent>
#include <QMenu>
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QWheelEvent>
namespace diag {
GridView::GridView(DiagramScene* scene, QWidget* parent)
: QGraphicsView(scene, parent), m_scene(scene)
{
setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
setDragMode(RubberBandDrag);
setTransformationAnchor(AnchorUnderMouse);
setResizeAnchor(AnchorViewCenter);
setAcceptDrops(true);
setMouseTracking(true);
viewport()->setMouseTracking(true);
}
double GridView::zoomFactor() const
{
return transform().m11();
}
void GridView::applyZoom(double factor)
{
const double current = zoomFactor();
const double target = qBound(0.1, current * factor, 8.0);
scale(target / current, target / current);
emit zoomChanged(zoomFactor());
}
void GridView::zoomIn()
{
applyZoom(1.2);
}
void GridView::zoomOut()
{
applyZoom(1.0 / 1.2);
}
void GridView::zoomReset()
{
resetTransform();
emit zoomChanged(zoomFactor());
}
void GridView::zoomToFit()
{
const QRectF bounds = scene()->itemsBoundingRect();
if (bounds.isEmpty())
return;
fitInView(bounds.adjusted(-40, -40, 40, 40), Qt::KeepAspectRatio);
emit zoomChanged(zoomFactor());
}
void GridView::drawBackground(QPainter* painter, const QRectF& rect)
{
const Theme& theme = m_scene->theme();
painter->fillRect(rect, theme.canvasBackground);
const double step = m_scene->routeConfig().gridStep;
if (step <= 0 || zoomFactor() * step < 4)
return;
const double left = std::floor(rect.left() / step) * step;
const double top = std::floor(rect.top() / step) * step;
QList<QLineF> minor;
QList<QLineF> major;
const int majorEvery = 5;
for (double x = left; x <= rect.right(); x += step) {
const bool isMajor = qAbs(std::fmod(x, step * majorEvery)) < 1e-6;
(isMajor ? major : minor).append(QLineF(x, rect.top(), x, rect.bottom()));
}
for (double y = top; y <= rect.bottom(); y += step) {
const bool isMajor = qAbs(std::fmod(y, step * majorEvery)) < 1e-6;
(isMajor ? major : minor).append(QLineF(rect.left(), y, rect.right(), y));
}
painter->setPen(QPen(theme.gridMinor, 0));
painter->drawLines(minor.data(), minor.size());
painter->setPen(QPen(theme.gridMajor, 0));
painter->drawLines(major.data(), major.size());
}
void GridView::wheelEvent(QWheelEvent* event)
{
if (event->modifiers() & Qt::ControlModifier) {
applyZoom(event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15);
event->accept();
return;
}
QGraphicsView::wheelEvent(event);
}
void GridView::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::MiddleButton) {
m_panning = true;
m_panStart = event->pos();
setCursor(Qt::ClosedHandCursor);
event->accept();
return;
}
QGraphicsView::mousePressEvent(event);
}
void GridView::mouseMoveEvent(QMouseEvent* event)
{
emit cursorMoved(mapToScene(event->pos()));
if (m_panning) {
const QPoint d = event->pos() - m_panStart;
m_panStart = event->pos();
horizontalScrollBar()->setValue(horizontalScrollBar()->value() - d.x());
verticalScrollBar()->setValue(verticalScrollBar()->value() - d.y());
event->accept();
return;
}
QGraphicsView::mouseMoveEvent(event);
}
void GridView::mouseReleaseEvent(QMouseEvent* event)
{
if (event->button() == Qt::MiddleButton && m_panning) {
m_panning = false;
unsetCursor();
event->accept();
return;
}
QGraphicsView::mouseReleaseEvent(event);
}
void GridView::dragEnterEvent(QDragEnterEvent* event)
{
if (event->mimeData()->hasFormat(QLatin1String(kNodeTypeMime)))
event->acceptProposedAction();
}
void GridView::dragMoveEvent(QDragMoveEvent* event)
{
if (event->mimeData()->hasFormat(QLatin1String(kNodeTypeMime)))
event->acceptProposedAction();
}
void GridView::dropEvent(QDropEvent* event)
{
if (handleNodeTypeDrop(event->mimeData(), event->position().toPoint()))
event->acceptProposedAction();
}
void GridView::contextMenuEvent(QContextMenuEvent* event)
{
QGraphicsItem* item = itemAt(event->pos());
QMenu menu(this);
if (item) {
// Right-clicking an unselected element makes it the selection.
if (!item->isSelected()) {
scene()->clearSelection();
item->setSelected(true);
}
menu.addAction(tr("Copy"), m_scene, &DiagramScene::copySelection);
menu.addAction(tr("Cut"), m_scene, &DiagramScene::cutSelection);
menu.addAction(tr("Delete"), m_scene, &DiagramScene::deleteSelection);
} else {
QAction* pasteAct = menu.addAction(tr("Paste"), m_scene, &DiagramScene::paste);
pasteAct->setEnabled(m_scene->canPaste());
menu.addAction(tr("Select All"), m_scene, &DiagramScene::selectAll);
menu.addAction(tr("Zoom to Fit"), this, &GridView::zoomToFit);
}
menu.exec(event->globalPos());
}
bool GridView::handleNodeTypeDrop(const QMimeData* mime, QPoint viewportPos)
{
if (!mime || !mime->hasFormat(QLatin1String(kNodeTypeMime)))
return false;
const QString typeId = QString::fromUtf8(mime->data(QLatin1String(kNodeTypeMime)));
return !m_scene->dropNode(typeId, mapToScene(viewportPos)).isNull();
}
} // namespace diag

54
src/scene/GridView.h Normal file
View File

@ -0,0 +1,54 @@
#pragma once
#include <QGraphicsView>
class QMimeData;
namespace diag {
class DiagramScene;
// Canvas view: draws the rectangular grid, zooms with Ctrl+wheel (anchored
// under the cursor), pans with the middle mouse button, rubber-band selects,
// and accepts node-type drops from the palette.
class GridView : public QGraphicsView {
Q_OBJECT
public:
static constexpr const char* kNodeTypeMime = "application/x-diag-nodetype";
explicit GridView(DiagramScene* scene, QWidget* parent = nullptr);
void zoomIn();
void zoomOut();
void zoomReset();
void zoomToFit();
double zoomFactor() const;
// Decodes a node-type mime payload and drops it at the viewport position;
// returns true when a node was created. Called from dropEvent.
bool handleNodeTypeDrop(const QMimeData* mime, QPoint viewportPos);
signals:
void zoomChanged(double factor);
void cursorMoved(QPointF scenePos);
protected:
void drawBackground(QPainter* painter, const QRectF& rect) override;
void wheelEvent(QWheelEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void dragEnterEvent(QDragEnterEvent* event) override;
void dragMoveEvent(QDragMoveEvent* event) override;
void dropEvent(QDropEvent* event) override;
void contextMenuEvent(QContextMenuEvent* event) override;
private:
void applyZoom(double factor);
DiagramScene* m_scene;
bool m_panning = false;
QPoint m_panStart;
};
} // namespace diag

183
src/scene/NodeItem.cpp Normal file
View File

@ -0,0 +1,183 @@
#include "NodeItem.h"
#include "DiagramScene.h"
#include "PortItem.h"
#include "core/NetworkModel.h"
#include "core/Router.h"
#include <QPainter>
#include <QSvgRenderer>
namespace diag {
namespace {
constexpr double kTitleHeight = 18.0;
constexpr double kMargin = 4.0;
}
NodeItem::NodeItem(DiagramScene* scene, QUuid nodeId) : m_scene(scene), m_id(nodeId)
{
setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges);
setAcceptHoverEvents(true);
setZValue(1);
const NodeType* type = nodeType();
if (type) {
for (const auto& spec : type->ports) {
auto* port = new PortItem(this, spec);
port->setPos(spec.pos.x() * type->size.width(), spec.pos.y() * type->size.height());
m_ports.append(port);
}
}
syncFromModel();
updatePortVisibility();
}
NetworkModel* NodeItem::model() const
{
return m_scene->model();
}
const NodeType* NodeItem::nodeType() const
{
return model()->nodeTypeOf(m_id);
}
PortItem* NodeItem::portItem(const QString& portId) const
{
for (auto* p : m_ports)
if (p->spec().id == portId)
return p;
return nullptr;
}
void NodeItem::syncFromModel()
{
const Node* n = model()->node(m_id);
if (!n)
return;
m_syncing = true;
if (pos() != n->pos)
setPos(n->pos);
m_syncing = false;
update();
}
void NodeItem::updatePortVisibility()
{
const bool connecting = m_scene->isConnecting();
for (auto* p : m_ports) {
bool visible = isSelected() || m_hovered;
if (connecting) {
const auto state = m_scene->connectStateFor(p);
visible = visible || state == PortItem::ConnectState::Compatible;
p->setConnectState(state);
} else {
p->setConnectState(PortItem::ConnectState::Normal);
}
p->setVisible(visible);
}
}
QRectF NodeItem::nodeRect() const
{
const NodeType* type = nodeType();
const QSizeF s = type ? type->size : QSizeF(60, 60);
return {0, 0, s.width(), s.height()};
}
QRectF NodeItem::boundingRect() const
{
return nodeRect().adjusted(-kMargin - 4, -kMargin - 4, kMargin + 4,
kMargin + kTitleHeight + 4);
}
QPainterPath NodeItem::shape() const
{
QPainterPath path;
path.addRect(nodeRect().adjusted(-kMargin, -kMargin, kMargin, kMargin));
return path;
}
void NodeItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*)
{
const Theme& theme = m_scene->theme();
const QRectF rect = nodeRect();
painter->setRenderHint(QPainter::Antialiasing);
if (QSvgRenderer* renderer = m_scene->rendererFor(model()->node(m_id)->typeId);
renderer && renderer->isValid()) {
renderer->render(painter, rect);
} else {
painter->setPen(QPen(theme.nodeOutline, 1.5));
painter->setBrush(Qt::NoBrush);
painter->drawRoundedRect(rect, 6, 6);
const NodeType* type = nodeType();
painter->drawText(rect, Qt::AlignCenter, type ? type->name : QStringLiteral("?"));
}
if (isSelected()) {
QPen pen(theme.selection, 1.2, Qt::DashLine);
painter->setPen(pen);
painter->setBrush(Qt::NoBrush);
painter->drawRect(rect.adjusted(-kMargin, -kMargin, kMargin, kMargin));
}
const QString title = model()->nodeProperty(m_id, QStringLiteral("title")).toString();
const QString text = title.isEmpty() ? (nodeType() ? nodeType()->name : QString()) : title;
if (!text.isEmpty()) {
const QString colorName =
model()->nodeProperty(m_id, QStringLiteral("labelColor")).toString();
const QColor c = QColor::isValidColorName(colorName) ? QColor(colorName) : theme.label;
painter->setPen(c);
QFont f = painter->font();
f.setPointSizeF(8.5);
painter->setFont(f);
painter->drawText(QRectF(rect.left() - 30, rect.bottom() + kMargin, rect.width() + 60,
kTitleHeight),
Qt::AlignHCenter | Qt::AlignTop, text);
}
}
QVariant NodeItem::itemChange(GraphicsItemChange change, const QVariant& value)
{
if (change == ItemPositionChange && !m_syncing && m_scene->snapEnabled()) {
return Router::snapPoint(value.toPointF(), m_scene->routeConfig().gridStep);
}
if (change == ItemPositionHasChanged && !m_syncing) {
// Live-sync the model while dragging so edges follow; the undoable
// MoveNodesCommand is created on mouse release.
model()->moveNode(m_id, pos());
}
if (change == ItemSelectedHasChanged) {
updatePortVisibility();
update();
}
return QGraphicsItem::itemChange(change, value);
}
void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent* event)
{
QGraphicsItem::mousePressEvent(event);
m_scene->captureMoveOrigin();
}
void NodeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
{
QGraphicsItem::mouseReleaseEvent(event);
m_scene->commitMove();
}
void NodeItem::hoverEnterEvent(QGraphicsSceneHoverEvent*)
{
m_hovered = true;
updatePortVisibility();
}
void NodeItem::hoverLeaveEvent(QGraphicsSceneHoverEvent*)
{
m_hovered = false;
updatePortVisibility();
}
} // namespace diag

53
src/scene/NodeItem.h Normal file
View File

@ -0,0 +1,53 @@
#pragma once
#include "core/Types.h"
#include <QGraphicsItem>
namespace diag {
class DiagramScene;
class NetworkModel;
class PortItem;
class QSvgRendererPtr;
// A node on the canvas, rendered from the node type's SVG. Ports appear when
// the node is selected or hovered, or while a compatible connection drag is
// running. Dragging snaps to the grid and is undoable.
class NodeItem : public QGraphicsItem {
public:
NodeItem(DiagramScene* scene, QUuid nodeId);
QUuid nodeId() const { return m_id; }
NetworkModel* model() const;
const NodeType* nodeType() const;
QList<PortItem*> portItems() const { return m_ports; }
PortItem* portItem(const QString& portId) const;
// Re-read model state (position, title) after external changes.
void syncFromModel();
void updatePortVisibility();
QRectF boundingRect() const override;
QPainterPath shape() const override;
void paint(QPainter* painter, const QStyleOptionGraphicsItem* option,
QWidget* widget) override;
protected:
QVariant itemChange(GraphicsItemChange change, const QVariant& value) override;
void mousePressEvent(QGraphicsSceneMouseEvent* event) override;
void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent* event) override;
private:
QRectF nodeRect() const;
DiagramScene* m_scene;
QUuid m_id;
QList<PortItem*> m_ports;
bool m_syncing = false;
bool m_hovered = false;
};
} // namespace diag

126
src/scene/PortItem.cpp Normal file
View File

@ -0,0 +1,126 @@
#include "PortItem.h"
#include "DiagramScene.h"
#include "NodeItem.h"
#include <QCursor>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
namespace diag {
namespace {
constexpr double kRadius = 5.0;
}
PortItem::PortItem(NodeItem* parent, const PortSpec& spec) : QGraphicsItem(parent), m_node(parent), m_spec(spec)
{
setAcceptHoverEvents(true);
setCursor(Qt::CrossCursor);
setZValue(2);
const Relation* rel = parent->model()->registry()->relation(spec.relation);
setToolTip(QStringLiteral("%1 · %2 · %3")
.arg(spec.id, rel ? rel->name : spec.relation,
portDirectionToString(spec.direction)));
}
DiagramScene* PortItem::diagramScene() const
{
return qobject_cast<DiagramScene*>(scene());
}
void PortItem::setConnectState(ConnectState s)
{
if (m_state == s)
return;
m_state = s;
update();
}
QRectF PortItem::boundingRect() const
{
const double r = kRadius + 3;
return {-r, -r, 2 * r, 2 * r};
}
void PortItem::paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*)
{
const DiagramScene* ds = diagramScene();
const Theme& theme = ds->theme();
const Relation* rel = m_node->model()->registry()->relation(m_spec.relation);
QColor fill = theme.relationColor(rel);
QColor stroke = theme.portStroke;
double r = kRadius;
switch (m_state) {
case ConnectState::Compatible:
r = kRadius + 2;
stroke = theme.selection;
break;
case ConnectState::Incompatible:
fill.setAlpha(60);
stroke.setAlpha(60);
break;
case ConnectState::Normal:
if (m_hovered)
r = kRadius + 1.5;
break;
}
painter->setRenderHint(QPainter::Antialiasing);
painter->setPen(QPen(stroke, m_state == ConnectState::Compatible ? 2.0 : 1.2));
if (m_spec.direction == PortDirection::In) {
// Hollow ring marks an input.
painter->setBrush(theme.canvasBackground);
painter->drawEllipse(QPointF(0, 0), r, r);
painter->setBrush(fill);
painter->drawEllipse(QPointF(0, 0), r * 0.45, r * 0.45);
} else {
painter->setBrush(fill);
painter->drawEllipse(QPointF(0, 0), r, r);
}
}
void PortItem::mousePressEvent(QGraphicsSceneMouseEvent* event)
{
if (event->button() == Qt::LeftButton) {
diagramScene()->startConnection(this);
event->accept();
return;
}
QGraphicsItem::mousePressEvent(event);
}
void PortItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
{
if (diagramScene()->isConnecting()) {
diagramScene()->updateConnectionDrag(event->scenePos());
event->accept();
return;
}
QGraphicsItem::mouseMoveEvent(event);
}
void PortItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
{
if (diagramScene()->isConnecting()) {
diagramScene()->finishConnection(event->scenePos());
event->accept();
return;
}
QGraphicsItem::mouseReleaseEvent(event);
}
void PortItem::hoverEnterEvent(QGraphicsSceneHoverEvent*)
{
m_hovered = true;
update();
}
void PortItem::hoverLeaveEvent(QGraphicsSceneHoverEvent*)
{
m_hovered = false;
update();
}
} // namespace diag

46
src/scene/PortItem.h Normal file
View File

@ -0,0 +1,46 @@
#pragma once
#include "core/Types.h"
#include <QGraphicsItem>
namespace diag {
class NodeItem;
class DiagramScene;
// A connection point on a node. Visible when the node is focused/hovered or
// while a compatible connection drag is in progress. Pressing a port starts
// an interactive edge drag.
class PortItem : public QGraphicsItem {
public:
enum class ConnectState { Normal, Compatible, Incompatible };
PortItem(NodeItem* parent, const PortSpec& spec);
const PortSpec& spec() const { return m_spec; }
NodeItem* nodeItem() const { return m_node; }
void setConnectState(ConnectState s);
QRectF boundingRect() const override;
void paint(QPainter* painter, const QStyleOptionGraphicsItem* option,
QWidget* widget) override;
protected:
void mousePressEvent(QGraphicsSceneMouseEvent* event) override;
void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override;
void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent* event) override;
private:
DiagramScene* diagramScene() const;
NodeItem* m_node;
PortSpec m_spec;
ConnectState m_state = ConnectState::Normal;
bool m_hovered = false;
};
} // namespace diag

70
src/scene/Theme.cpp Normal file
View File

@ -0,0 +1,70 @@
#include "Theme.h"
namespace diag {
QColor Theme::relationColor(const Relation* r) const
{
if (!r)
return scheme == ColorScheme::Light ? QColor(0x50, 0x50, 0x50) : QColor(0xb0, 0xb0, 0xb0);
return scheme == ColorScheme::Light ? r->color : r->darkColor;
}
Theme Theme::make(ColorScheme scheme)
{
Theme t;
t.scheme = scheme;
switch (scheme) {
case ColorScheme::Light:
t.canvasBackground = QColor(0xfa, 0xfa, 0xf7);
t.gridMinor = QColor(0xe8, 0xe8, 0xe2);
t.gridMajor = QColor(0xd5, 0xd5, 0xcd);
t.nodeOutline = QColor(0x3a, 0x3a, 0x3a);
t.selection = QColor(0x1a, 0x73, 0xe8);
t.label = QColor(0x20, 0x20, 0x20);
t.portStroke = QColor(0x28, 0x28, 0x28);
t.flowOverlay = QColor(0xff, 0xff, 0xff, 0xd0);
break;
case ColorScheme::Dark:
t.canvasBackground = QColor(0x1e, 0x21, 0x26);
t.gridMinor = QColor(0x2a, 0x2e, 0x35);
t.gridMajor = QColor(0x3a, 0x40, 0x4a);
t.nodeOutline = QColor(0xd0, 0xd0, 0xd0);
t.selection = QColor(0x6a, 0xb0, 0xff);
t.label = QColor(0xe0, 0xe0, 0xe0);
t.portStroke = QColor(0xe8, 0xe8, 0xe8);
t.flowOverlay = QColor(0xff, 0xff, 0xff, 0xb0);
break;
case ColorScheme::HighContrast:
t.canvasBackground = QColor(Qt::white);
t.gridMinor = QColor(0xc8, 0xc8, 0xc8);
t.gridMajor = QColor(0x80, 0x80, 0x80);
t.nodeOutline = QColor(Qt::black);
t.selection = QColor(0xd0, 0x00, 0x70);
t.label = QColor(Qt::black);
t.portStroke = QColor(Qt::black);
t.flowOverlay = QColor(0x00, 0x00, 0x00, 0xd0);
break;
}
return t;
}
QString Theme::schemeName(ColorScheme scheme)
{
switch (scheme) {
case ColorScheme::Light: return QStringLiteral("Light");
case ColorScheme::Dark: return QStringLiteral("Dark");
case ColorScheme::HighContrast: return QStringLiteral("High contrast");
}
return QStringLiteral("Light");
}
ColorScheme Theme::schemeFromName(const QString& name)
{
if (name == QLatin1String("Dark"))
return ColorScheme::Dark;
if (name == QLatin1String("High contrast"))
return ColorScheme::HighContrast;
return ColorScheme::Light;
}
} // namespace diag

31
src/scene/Theme.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include "core/Types.h"
#include <QColor>
namespace diag {
enum class ColorScheme { Light, Dark, HighContrast };
// Canvas palette per color scheme; relation colors come from the registry
// (light or dark variant picked here).
struct Theme {
ColorScheme scheme = ColorScheme::Light;
QColor canvasBackground;
QColor gridMinor;
QColor gridMajor;
QColor nodeOutline;
QColor selection;
QColor label;
QColor portStroke;
QColor flowOverlay;
QColor relationColor(const Relation* r) const;
static Theme make(ColorScheme scheme);
static QString schemeName(ColorScheme scheme);
static ColorScheme schemeFromName(const QString& name);
};
} // namespace diag

View File

@ -9,3 +9,13 @@ endfunction()
diag_add_test(tst_model diagcore) diag_add_test(tst_model diagcore)
diag_add_test(tst_properties diagcore) diag_add_test(tst_properties diagcore)
diag_add_test(tst_router diagcore) diag_add_test(tst_router diagcore)
diag_add_test(tst_solver diagcore)
diag_add_test(tst_json diagcore)
diag_add_test(tst_undo diagscene)
diag_add_test(tst_scene diagscene)
qt_add_resources(TEST_RESOURCES ${CMAKE_SOURCE_DIR}/resources/resources.qrc)
add_executable(tst_app tst_app.cpp ${TEST_RESOURCES})
target_link_libraries(tst_app PRIVATE diagapp Qt6::Test)
add_test(NAME tst_app COMMAND tst_app)
set_tests_properties(tst_app PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")

138
tests/tst_app.cpp Normal file
View File

@ -0,0 +1,138 @@
// End-to-end scenarios against the real MainWindow with bundled resources
// (registry config, SVGs, sample document), offscreen.
#include "app/MainWindow.h"
#include "app/PropertyPanel.h"
#include "core/FlowSolver.h"
#include "core/NetworkModel.h"
#include "scene/DiagramScene.h"
#include "scene/EdgeItem.h"
#include "scene/NodeItem.h"
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QGroupBox>
#include <QLineEdit>
#include <QTest>
#include <QUndoStack>
using namespace diag;
class TestApp : public QObject {
Q_OBJECT
private slots:
void initTestCase()
{
Q_INIT_RESOURCE(resources);
}
void registryLoads_fromBundledConfig()
{
MainWindow window;
const TypeRegistry* reg = window.model()->registry();
QVERIFY(reg->nodeTypes().size() >= 9);
QVERIFY(reg->relation(QStringLiteral("water")));
QVERIFY(reg->relation(QStringLiteral("power")));
QVERIFY(reg->catalogue(QStringLiteral("pipe_series")));
QCOMPARE(reg->nodeGroups(),
(QStringList{QStringLiteral("Water supply"), QStringLiteral("Power")}));
}
void sampleLoads_andRoutes()
{
MainWindow window;
window.show();
window.loadSample();
QVERIFY(window.model()->nodeIds().size() >= 8);
QVERIFY(window.model()->edgeIds().size() >= 7);
// Every edge item has an orthogonal polyline attached to its ports.
for (QUuid id : window.model()->edgeIds()) {
EdgeItem* item = window.scene()->edgeItem(id);
QVERIFY(item);
QVERIFY(item->polyline().size() >= 2);
}
}
void selectingEdge_populatesGroupedPropertyPanel()
{
MainWindow window;
window.show();
window.loadSample();
const QUuid edgeId = window.model()->edgeIds().first();
window.scene()->clearSelection();
window.scene()->edgeItem(edgeId)->setSelected(true);
auto* panel = window.findChild<PropertyPanel*>();
QVERIFY(panel);
const auto groups = panel->findChildren<QGroupBox*>();
QStringList titles;
for (auto* g : groups)
titles << g->title();
QVERIFY(titles.contains(QStringLiteral("General")));
QVERIFY(titles.contains(QStringLiteral("Presentation")));
QVERIFY(titles.contains(QStringLiteral("Physics")));
// Style enums and physics editors exist.
QVERIFY(panel->findChildren<QComboBox*>().size() >= 3);
QVERIFY(panel->findChildren<QDoubleSpinBox*>().size() >= 2);
}
void editingTitle_isUndoable()
{
MainWindow window;
window.show();
window.loadSample();
const QUuid nodeId = window.model()->nodeIds().first();
window.scene()->clearSelection();
window.scene()->nodeItem(nodeId)->setSelected(true);
auto* panel = window.findChild<PropertyPanel*>();
auto* titleEdit = panel->findChild<QLineEdit*>();
QVERIFY(titleEdit);
titleEdit->setText(QStringLiteral("Renamed"));
emit titleEdit->editingFinished();
QCOMPARE(window.model()->nodeProperty(nodeId, "title").toString(),
QStringLiteral("Renamed"));
window.scene()->undoStack()->undo();
QCOMPARE(window.model()->nodeProperty(nodeId, "title").toString(),
QStringLiteral("Intake"));
}
void simulation_assignsFlowsAndAnimates()
{
MainWindow window;
window.show();
window.loadSample();
window.scene()->setFlowAnimationEnabled(true);
// Solve both networks like the Simulation menu action does.
for (const Relation& rel : window.model()->registry()->relations())
FlowSolver::apply(*window.model(),
FlowSolver::solve(*window.model(), rel.id));
// Water main from the intake carries the full balanced demand.
double maxWater = 0;
for (QUuid id : window.model()->edgeIds())
if (window.model()->edge(id)->relation == QLatin1String("water"))
maxWater = qMax(maxWater, qAbs(window.model()->edge(id)->flowRate));
QVERIFY(maxWater > 1.0);
QVERIFY(window.scene()->maxAbsFlow() >= maxWater);
const double phase0 = window.scene()->animationPhase();
QTRY_VERIFY_WITH_TIMEOUT(window.scene()->animationPhase() > phase0, 1000);
window.scene()->setFlowAnimationEnabled(false);
}
void screenshot_rendersWithoutCrash()
{
MainWindow window;
window.resize(1000, 700);
window.show();
window.loadSample();
const QPixmap shot = window.grab();
QVERIFY(!shot.isNull());
QVERIFY(shot.width() > 0);
}
};
QTEST_MAIN(TestApp)
#include "tst_app.moc"

84
tests/tst_json.cpp Normal file
View File

@ -0,0 +1,84 @@
#include "TestFixtures.h"
#include "core/JsonIo.h"
#include "core/NetworkModel.h"
#include <QTemporaryDir>
#include <QTest>
using namespace diag;
class TestJson : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_model;
m_model = new NetworkModel(&m_reg, this);
}
void roundTrip_preservesDocument()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {20, 40});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {220, 40});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {420, 40});
const QUuid e1 = m_model->addEdge(src, "out", pump, "in");
const QUuid e2 = m_model->addEdge(pump, "out", c, "in");
m_model->setNodeProperty(src, "title", QStringLiteral("Intake"));
m_model->setNodeProperty(src, "tags", QStringList{"a", "b"});
m_model->setEdgeProperty(e1, "diameter", 150.0);
m_model->setEdgeProperty(e1, "headDecoration", QStringLiteral("Diamond"));
m_model->setEdgeProperty(e2, "series", QStringLiteral("dn100"));
const QJsonObject doc = JsonIo::toJson(*m_model);
NetworkModel restored(&m_reg);
QString err;
QVERIFY2(JsonIo::fromJson(doc, &restored, &err), qPrintable(err));
QCOMPARE(restored.nodeIds().size(), 3);
QCOMPARE(restored.edgeIds().size(), 2);
QCOMPARE(restored.node(src)->pos, QPointF(20, 40));
QCOMPARE(restored.nodeProperty(src, "title").toString(), QStringLiteral("Intake"));
QCOMPARE(restored.nodeProperty(src, "tags").toStringList(), (QStringList{"a", "b"}));
QCOMPARE(restored.edgeProperty(e1, "diameter").toDouble(), 150.0);
QCOMPARE(restored.edgeProperty(e1, "headDecoration").toString(),
QStringLiteral("Diamond"));
QCOMPARE(restored.edgeProperty(e2, "series").toString(), QStringLiteral("dn100"));
QCOMPARE(restored.edge(e1)->fromNode, src);
QCOMPARE(restored.edge(e1)->toNode, pump);
QCOMPARE(restored.edge(e1)->relation, QStringLiteral("water"));
}
void saveAndLoad_file()
{
QTemporaryDir dir;
const QString path = dir.filePath(QStringLiteral("doc.json"));
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
m_model->addEdge(src, "out", c, "in");
QString err;
QVERIFY2(JsonIo::save(*m_model, path, &err), qPrintable(err));
NetworkModel restored(&m_reg);
QVERIFY2(JsonIo::load(&restored, path, &err), qPrintable(err));
QCOMPARE(restored.nodeIds().size(), 2);
QCOMPARE(restored.edgeIds().size(), 1);
}
void invalidDocument_rejected()
{
NetworkModel restored(&m_reg);
QString err;
QVERIFY(!JsonIo::fromJson(QJsonObject{{"format", "other"}}, &restored, &err));
QVERIFY(!err.isEmpty());
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
};
QTEST_GUILESS_MAIN(TestJson)
#include "tst_json.moc"

215
tests/tst_scene.cpp Normal file
View File

@ -0,0 +1,215 @@
#include "TestFixtures.h"
#include "core/NetworkModel.h"
#include "scene/DiagramScene.h"
#include "scene/EdgeItem.h"
#include "scene/GridView.h"
#include "scene/NodeItem.h"
#include "scene/PortItem.h"
#include <QMimeData>
#include <QSignalSpy>
#include <QTest>
#include <QUndoStack>
using namespace diag;
class TestScene : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_scene;
delete m_model;
m_model = new NetworkModel(&m_reg, this);
m_undo = new QUndoStack(m_model);
m_scene = new DiagramScene(m_model, m_undo, this);
}
void dropNode_createsSnappedItemAndUndoes()
{
const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {107, 93});
QVERIFY(!id.isNull());
QVERIFY(m_scene->nodeItem(id));
// Center (107,93) minus half size (30,30) = (77,63), snapped to 20-grid.
QCOMPARE(m_model->node(id)->pos, QPointF(80, 60));
QCOMPARE(m_scene->nodeItem(id)->pos(), QPointF(80, 60));
m_undo->undo();
QVERIFY(!m_scene->nodeItem(id));
}
void nodeDrag_snapsAndIsUndoable()
{
const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {100, 100});
NodeItem* item = m_scene->nodeItem(id);
const QPointF before = item->pos();
// Simulate the drag protocol NodeItem uses.
item->setSelected(true);
m_scene->captureMoveOrigin();
item->setPos(before + QPointF(37, 42)); // snapped by itemChange
m_scene->commitMove();
QCOMPARE(item->pos(), before + QPointF(40, 40));
QCOMPARE(m_model->node(id)->pos, before + QPointF(40, 40));
m_undo->undo();
QCOMPARE(m_model->node(id)->pos, before);
QCOMPARE(item->pos(), before);
}
void ports_visibleOnlyWhenFocused()
{
const QUuid id = m_scene->dropNode(QStringLiteral("pump"), {100, 100});
NodeItem* item = m_scene->nodeItem(id);
QCOMPARE(item->portItems().size(), 2);
for (auto* p : item->portItems())
QVERIFY(!p->isVisible());
item->setSelected(true);
for (auto* p : item->portItems())
QVERIFY(p->isVisible());
item->setSelected(false);
for (auto* p : item->portItems())
QVERIFY(!p->isVisible());
}
void interactiveConnect_compatiblePortsHighlighted()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {300, 0});
const QUuid gen = m_scene->dropNode(QStringLiteral("generator"), {300, 200});
PortItem* srcOut = m_scene->nodeItem(src)->portItem(QStringLiteral("out"));
PortItem* pumpIn = m_scene->nodeItem(pump)->portItem(QStringLiteral("in"));
PortItem* pumpOut = m_scene->nodeItem(pump)->portItem(QStringLiteral("out"));
PortItem* genOut = m_scene->nodeItem(gen)->portItem(QStringLiteral("out"));
m_scene->startConnection(srcOut);
QVERIFY(m_scene->isConnecting());
QCOMPARE(m_scene->connectStateFor(pumpIn), PortItem::ConnectState::Compatible);
QCOMPARE(m_scene->connectStateFor(pumpOut), PortItem::ConnectState::Incompatible);
QCOMPARE(m_scene->connectStateFor(genOut), PortItem::ConnectState::Incompatible);
QVERIFY(pumpIn->isVisible()); // compatible ports shown during drag
// Finish on the compatible port: edge created through the undo stack.
QVERIFY(m_scene->finishConnection(pumpIn->scenePos()));
QVERIFY(!m_scene->isConnecting());
QCOMPARE(m_model->edgeIds().size(), 1);
m_undo->undo();
QCOMPARE(m_model->edgeIds().size(), 0);
}
void interactiveConnect_rejectsIncompatible()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid gen = m_scene->dropNode(QStringLiteral("generator"), {300, 0});
PortItem* srcOut = m_scene->nodeItem(src)->portItem(QStringLiteral("out"));
PortItem* genOut = m_scene->nodeItem(gen)->portItem(QStringLiteral("out"));
QSignalSpy spy(m_scene, &DiagramScene::connectionMessage);
m_scene->startConnection(srcOut);
QVERIFY(!m_scene->finishConnection(genOut->scenePos()));
QCOMPARE(m_model->edgeIds().size(), 0);
QVERIFY(spy.count() >= 2); // start message + rejection reason
}
void edgeRoute_isOrthogonalBetweenPorts()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {400, 200});
const QUuid e = m_model->addEdge(src, "out", pump, "in");
EdgeItem* item = m_scene->edgeItem(e);
QVERIFY(item);
const auto& poly = item->polyline();
QVERIFY(poly.size() >= 2);
QCOMPARE(poly.first(), m_model->portScenePos(src, QStringLiteral("out")));
QCOMPARE(poly.last(), m_model->portScenePos(pump, QStringLiteral("in")));
for (int i = 0; i + 1 < poly.size(); ++i)
QVERIFY(qAbs(poly[i].x() - poly[i + 1].x()) < 1e-6
|| qAbs(poly[i].y() - poly[i + 1].y()) < 1e-6);
}
void edgesFollowNodeMove()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {400, 0});
const QUuid e = m_model->addEdge(src, "out", pump, "in");
m_model->moveNode(pump, {400, 300});
QCOMPARE(m_scene->edgeItem(e)->polyline().last(),
m_model->portScenePos(pump, QStringLiteral("in")));
}
void copyPasteDelete_roundTrip()
{
const QUuid src = m_scene->dropNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_scene->dropNode(QStringLiteral("pump"), {300, 0});
m_model->addEdge(src, "out", pump, "in");
m_scene->nodeItem(src)->setSelected(true);
m_scene->nodeItem(pump)->setSelected(true);
m_scene->copySelection();
QVERIFY(m_scene->canPaste());
m_scene->paste();
QCOMPARE(m_model->nodeIds().size(), 4);
QCOMPARE(m_model->edgeIds().size(), 2);
// Pasted nodes become the new selection.
QCOMPARE(m_scene->selectedItems().size(), 2);
m_scene->selectAll();
m_scene->deleteSelection();
QCOMPARE(m_model->nodeIds().size(), 0);
QCOMPARE(m_model->edgeIds().size(), 0);
m_undo->undo();
QCOMPARE(m_model->nodeIds().size(), 4);
QCOMPARE(m_model->edgeIds().size(), 2);
}
void crossingEdges_getArcWhenEnabled()
{
// Build two crossing water lines: (0,0)->(400,0) area and a vertical
// crossing through them.
const QUuid s1 = m_scene->dropNode(QStringLiteral("source"), {0, 100});
const QUuid c1 = m_scene->dropNode(QStringLiteral("consumer"), {400, 100});
const QUuid j1 = m_scene->dropNode(QStringLiteral("junction"), {200, 0});
const QUuid j2 = m_scene->dropNode(QStringLiteral("junction"), {200, 240});
m_model->addEdge(s1, "out", c1, "in");
const QUuid crossing = m_model->addEdge(j1, "s", j2, "n");
EdgeItem* item = m_scene->edgeItem(crossing);
const double plainLen = Router::polylineLength(item->polyline());
// With arcs on (default), the painted path detours around the first edge.
RouteConfig cfg = m_scene->routeConfig();
QVERIFY(cfg.arcOnIntersection);
const auto hops = Router::crossings(item->polyline(),
m_scene->edgeItem(m_model->edgeIds().first())->polyline());
QCOMPARE(hops.size(), 1);
cfg.arcOnIntersection = false;
m_scene->setRouteConfig(cfg); // still routes, no arcs — no crash, same polyline
QCOMPARE(Router::polylineLength(m_scene->edgeItem(crossing)->polyline()), plainLen);
}
void gridView_dropMimeCreatesNode()
{
GridView view(m_scene);
view.resize(600, 400);
view.show();
QMimeData mime;
mime.setData(QLatin1String(GridView::kNodeTypeMime), QByteArrayLiteral("pump"));
QVERIFY(view.handleNodeTypeDrop(&mime, QPoint(300, 200)));
QCOMPARE(m_model->nodeIds().size(), 1);
QMimeData other;
other.setData(QStringLiteral("text/plain"), QByteArrayLiteral("x"));
QVERIFY(!view.handleNodeTypeDrop(&other, QPoint(300, 200)));
QCOMPARE(m_model->nodeIds().size(), 1);
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
QUndoStack* m_undo = nullptr;
DiagramScene* m_scene = nullptr;
};
QTEST_MAIN(TestScene)
#include "tst_scene.moc"

123
tests/tst_solver.cpp Normal file
View File

@ -0,0 +1,123 @@
#include "TestFixtures.h"
#include "core/FlowSolver.h"
#include "core/NetworkModel.h"
#include <QSignalSpy>
#include <QTest>
using namespace diag;
class TestSolver : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_model;
m_model = new NetworkModel(&m_reg, this);
}
void chain_carriesSupplyThrough()
{
// source(10) -> pump -> consumer(10)
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});
const QUuid e1 = m_model->addEdge(src, "out", pump, "in");
const QUuid e2 = m_model->addEdge(pump, "out", c, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
QVERIFY(qAbs(r.edgeFlow.value(e1) - 10.0) < 1e-6);
QVERIFY(qAbs(r.edgeFlow.value(e2) - 10.0) < 1e-6);
}
void junction_conservesFlow()
{
// source(10) -> junction -> two consumers (arbitrary demands).
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid j = m_model->addNode(QStringLiteral("junction"), {200, 0});
const QUuid c1 = m_model->addNode(QStringLiteral("consumer"), {400, -100});
const QUuid c2 = m_model->addNode(QStringLiteral("consumer"), {400, 100});
m_model->setNodeProperty(c1, "demand", 4.0);
m_model->setNodeProperty(c2, "demand", 6.0);
const QUuid eIn = m_model->addEdge(src, "out", j, "w");
const QUuid eOut1 = m_model->addEdge(j, "n", c1, "in");
const QUuid eOut2 = m_model->addEdge(j, "s", c2, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
const double in = r.edgeFlow.value(eIn);
const double out = r.edgeFlow.value(eOut1) + r.edgeFlow.value(eOut2);
QVERIFY(qAbs(in - out) < 1e-6); // Kirchhoff at the junction
QVERIFY(qAbs(in - 10.0) < 1e-6); // all supply enters
QVERIFY(qAbs(r.edgeFlow.value(eOut1) - 4.0) < 1e-6);
QVERIFY(qAbs(r.edgeFlow.value(eOut2) - 6.0) < 1e-6);
}
void parallelPipes_widerCarriesMore()
{
// Two parallel pipes between two junctions; DN150 vs DN75.
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid j1 = m_model->addNode(QStringLiteral("junction"), {200, 0});
const QUuid j2 = m_model->addNode(QStringLiteral("junction"), {400, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {600, 0});
m_model->addEdge(src, "out", j1, "w");
const QUuid wide = m_model->addEdge(j1, "n", j2, "n");
const QUuid narrow = m_model->addEdge(j1, "s", j2, "s");
m_model->addEdge(j2, "e", c, "in");
m_model->setEdgeProperty(wide, "diameter", 150.0);
m_model->setEdgeProperty(narrow, "diameter", 75.0);
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
const double fWide = r.edgeFlow.value(wide);
const double fNarrow = r.edgeFlow.value(narrow);
QVERIFY(fWide > 0 && fNarrow > 0);
QVERIFY(qAbs(fWide + fNarrow - 10.0) < 1e-6);
// Conductance ~ d^4: ratio (150/75)^4 = 16.
QVERIFY(qAbs(fWide / fNarrow - 16.0) < 1e-6);
}
void unbalancedInjections_areNormalized()
{
// Supply 10 vs demand 4: imbalance is spread, but flow stays conserved.
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
m_model->setNodeProperty(c, "demand", 4.0);
const QUuid e = m_model->addEdge(src, "out", c, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
QVERIFY(qAbs(r.edgeFlow.value(e) - 7.0) < 1e-6); // (10 + 4) / 2
}
void otherRelation_untouched()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
m_model->addEdge(src, "out", c, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("power"));
QVERIFY(r.ok);
QVERIFY(r.edgeFlow.isEmpty());
}
void apply_writesFlowsAndSignals()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
const QUuid e = m_model->addEdge(src, "out", c, "in");
QSignalSpy spy(m_model, &NetworkModel::flowChanged);
FlowSolver::apply(*m_model, FlowSolver::solve(*m_model, QStringLiteral("water")));
QCOMPARE(spy.count(), 1);
QVERIFY(m_model->edge(e)->flowRate > 0);
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
};
QTEST_GUILESS_MAIN(TestSolver)
#include "tst_solver.moc"

137
tests/tst_undo.cpp Normal file
View File

@ -0,0 +1,137 @@
#include "TestFixtures.h"
#include "core/NetworkModel.h"
#include "scene/Commands.h"
#include <QTest>
#include <QUndoStack>
using namespace diag;
class TestUndo : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_model;
m_model = new NetworkModel(&m_reg, this);
m_undo.clear();
}
void addNode_undoRedo()
{
auto* cmd = new AddNodeCommand(m_model, QStringLiteral("pump"), {100, 100});
m_undo.push(cmd);
const QUuid id = cmd->nodeId();
QVERIFY(m_model->node(id));
m_undo.undo();
QVERIFY(!m_model->node(id));
m_undo.redo();
QVERIFY(m_model->node(id)); // same id restored
}
void addEdge_undoRedo()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
auto* cmd = new AddEdgeCommand(m_model, src, QStringLiteral("out"), pump,
QStringLiteral("in"));
m_undo.push(cmd);
QVERIFY(m_model->edge(cmd->edgeId()));
m_undo.undo();
QCOMPARE(m_model->edgeIds().size(), 0);
m_undo.redo();
QCOMPARE(m_model->edgeIds().size(), 1);
}
void moveNodes_undoRestoresPositions()
{
const QUuid a = m_model->addNode(QStringLiteral("pump"), {0, 0});
const QUuid b = m_model->addNode(QStringLiteral("pump"), {100, 0});
m_undo.push(new MoveNodesCommand(
m_model, {{a, {0, 0}, {40, 40}}, {b, {100, 0}, {140, 40}}}));
QCOMPARE(m_model->node(a)->pos, QPointF(40, 40));
m_undo.undo();
QCOMPARE(m_model->node(a)->pos, QPointF(0, 0));
QCOMPARE(m_model->node(b)->pos, QPointF(100, 0));
}
void removeItems_undoRestoresEdgesAndProps()
{
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});
const QUuid e1 = m_model->addEdge(src, "out", pump, "in");
const QUuid e2 = m_model->addEdge(pump, "out", c, "in");
m_model->setEdgeProperty(e1, "diameter", 150.0);
m_model->setNodeProperty(pump, "title", QStringLiteral("P-1"));
// Deleting the pump takes both attached edges with it.
m_undo.push(new RemoveItemsCommand(m_model, {pump}, {}));
QCOMPARE(m_model->nodeIds().size(), 2);
QCOMPARE(m_model->edgeIds().size(), 0);
m_undo.undo();
QCOMPARE(m_model->nodeIds().size(), 3);
QCOMPARE(m_model->edgeIds().size(), 2);
QVERIFY(m_model->edge(e2));
QCOMPARE(m_model->edgeProperty(e1, "diameter").toDouble(), 150.0);
QCOMPARE(m_model->nodeProperty(pump, "title").toString(), QStringLiteral("P-1"));
}
void setProperty_undoRedo()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
m_undo.push(new SetPropertyCommand(m_model, SetPropertyCommand::NodeTarget, src,
QStringLiteral("supply"), 25.0));
QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 25.0);
m_undo.undo();
QCOMPARE(m_model->nodeProperty(src, "supply").toDouble(), 10.0); // spec default
}
void paste_remapsIdsAndOffsets()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
m_model->addEdge(src, "out", pump, "in");
const QJsonObject frag = fragmentFromSelection(*m_model, {src, pump}, {});
auto* cmd = new PasteCommand(m_model, frag, {20, 20});
m_undo.push(cmd);
QCOMPARE(m_model->nodeIds().size(), 4);
QCOMPARE(m_model->edgeIds().size(), 2); // inner edge duplicated
const QList<QUuid> pasted = cmd->pastedNodes();
QCOMPARE(pasted.size(), 2);
QVERIFY(!pasted.contains(src));
QCOMPARE(m_model->node(pasted.first())->pos,
m_model->node(src)->pos + QPointF(20, 20));
m_undo.undo();
QCOMPARE(m_model->nodeIds().size(), 2);
QCOMPARE(m_model->edgeIds().size(), 1);
m_undo.redo();
QCOMPARE(m_model->nodeIds().size(), 4);
}
void copyFragment_skipsDanglingEdges()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
m_model->addEdge(src, "out", pump, "in");
// Only the pump is copied: the edge to the source must not paste.
const QJsonObject frag = fragmentFromSelection(*m_model, {pump}, {});
auto* cmd = new PasteCommand(m_model, frag, {0, 40});
m_undo.push(cmd);
QCOMPARE(m_model->nodeIds().size(), 3);
QCOMPARE(m_model->edgeIds().size(), 1);
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
QUndoStack m_undo;
};
QTEST_GUILESS_MAIN(TestUndo)
#include "tst_undo.moc"