Compare commits
No commits in common. "6990bff8090259269fbe38ca5e17171c30ca8124" and "18d614ec6775eafe4d46860cb61ba1c5aee516cb" have entirely different histories.
6990bff809
...
18d614ec67
37
README.md
@ -4,49 +4,18 @@ 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
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||

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