chartflow 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chartflow/__init__.py +28 -0
- chartflow/_verify_importexport.py +169 -0
- chartflow/edit/__init__.py +119 -0
- chartflow/edit/code_editor.py +1007 -0
- chartflow/edit/code_folder.py +523 -0
- chartflow/edit/completer.py +2652 -0
- chartflow/edit/context_analyzer.py +521 -0
- chartflow/edit/demo.py +469 -0
- chartflow/edit/line_number_area.py +548 -0
- chartflow/edit/minimap.py +581 -0
- chartflow/edit/python_editor.py +509 -0
- chartflow/edit/syntax_highlighter.py +291 -0
- chartflow/edit/test_editor.py +190 -0
- chartflow/flow/__init__.py +31 -0
- chartflow/flow/_demo.py +98 -0
- chartflow/flow/chart.py +1101 -0
- chartflow/flow/editor.py +375 -0
- chartflow/flow/flow.py +6 -0
- chartflow/flow/qchart.py +1250 -0
- chartflow/flow/test/__init__.py +0 -0
- chartflow/flow/test/test_chart.py +537 -0
- chartflow/flow/test_decorator_sync.py +102 -0
- chartflow/fsm/__init__.py +84 -0
- chartflow/fsm/decorators.py +395 -0
- chartflow/fsm/demo.py +381 -0
- chartflow/fsm/demo_pyqt6.py +881 -0
- chartflow/fsm/demo_tree.py +46 -0
- chartflow/fsm/logic.py +447 -0
- chartflow/fsm/meta.py +569 -0
- chartflow/fsm/scheduler.py +427 -0
- chartflow/fsm/stage.py +2079 -0
- chartflow/fsm/stdio.py +66 -0
- chartflow/fsm/test/__init__.py +7 -0
- chartflow/fsm/test/test_decorators.py +210 -0
- chartflow/fsm/test/test_events.py +202 -0
- chartflow/fsm/test/test_integration.py +596 -0
- chartflow/fsm/test/test_logic.py +371 -0
- chartflow/fsm/test/test_meta.py +192 -0
- chartflow/fsm/test/test_runtime_param.py +336 -0
- chartflow/fsm/test/test_scheduler.py +280 -0
- chartflow/fsm/test/test_stage.py +314 -0
- chartflow/fsm/test_behavior_events.py +263 -0
- chartflow/fsm/test_nested_parallel.py +80 -0
- chartflow/fsm/test_stage_spec.py +448 -0
- chartflow/fsm/utils.py +34 -0
- chartflow/node/__init__.py +66 -0
- chartflow/node/_node_base.py +727 -0
- chartflow/node/_node_interaction.py +488 -0
- chartflow/node/_node_interface.py +426 -0
- chartflow/node/_node_markers.py +648 -0
- chartflow/node/_node_ports.py +536 -0
- chartflow/node/_port_interface.py +111 -0
- chartflow/node/arrow_item.py +287 -0
- chartflow/node/color_utils.py +113 -0
- chartflow/node/connection_item.py +939 -0
- chartflow/node/demo.py +258 -0
- chartflow/node/demo_arrow_drag.py +46 -0
- chartflow/node/demo_decorator.py +63 -0
- chartflow/node/demo_ports.py +69 -0
- chartflow/node/enums.py +35 -0
- chartflow/node/example.py +84 -0
- chartflow/node/layout_utils.py +307 -0
- chartflow/node/main_window.py +196 -0
- chartflow/node/menu_bar.py +240 -0
- chartflow/node/node_canvas.py +1730 -0
- chartflow/node/node_item.py +754 -0
- chartflow/node/popmenu.py +472 -0
- chartflow/node/port_item.py +591 -0
- chartflow/node/qnode_editor.py +73 -0
- chartflow/node/selection_rect.py +53 -0
- chartflow/node/style_panel.py +615 -0
- chartflow/node/styles.py +63 -0
- chartflow/node/test_qnode.py +2336 -0
- chartflow/showcase.py +528 -0
- chartflow/theme.py +508 -0
- chartflow-0.1.dist-info/METADATA +23 -0
- chartflow-0.1.dist-info/RECORD +79 -0
- chartflow-0.1.dist-info/WHEEL +5 -0
- chartflow-0.1.dist-info/top_level.txt +1 -0
chartflow/flow/chart.py
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
qflow.chart - IChart interface for qnode <-> qfsm conversion
|
|
6
|
+
|
|
7
|
+
This module provides the IChart interface that serves as a data carrier
|
|
8
|
+
describing a node graph and its logic, enabling conversion between:
|
|
9
|
+
- qnode JSON format (visual node graph)
|
|
10
|
+
- qfsm Stage/Logic classes (finite state machine)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from chartflow.fsm.stage import Stage
|
|
14
|
+
from chartflow.fsm.logic import Logic
|
|
15
|
+
import ast
|
|
16
|
+
import inspect
|
|
17
|
+
from PyQt6.QtWidgets import QApplication
|
|
18
|
+
from PyQt6.QtCore import Qt, QRectF, QPointF
|
|
19
|
+
from PyQt6.QtGui import QPainter, QPixmap, QColor
|
|
20
|
+
from chartflow.node import QNodeCanvas, NodeShape, ConnectionType
|
|
21
|
+
from chartflow.node.layout_utils import DenseLayoutEngine, ViewportFitter
|
|
22
|
+
from PIL import Image
|
|
23
|
+
import tempfile
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
from PyQt6.QtGui import QColor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
from abc import ABC, abstractmethod
|
|
31
|
+
from typing import Any, ClassVar, Type, TYPE_CHECKING, Union
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ChartNode:
|
|
36
|
+
"""
|
|
37
|
+
Data class representing a node in the chart.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
nid: Unique node identifier
|
|
41
|
+
name: Node display name
|
|
42
|
+
ntype: Node type (state, logic, entry, exit, etc.)
|
|
43
|
+
shape: Visual shape (ellipse, rectangle, diamond)
|
|
44
|
+
data: Custom node data
|
|
45
|
+
states: List of state names if this is a compound node
|
|
46
|
+
custom: Custom marker configuration
|
|
47
|
+
"""
|
|
48
|
+
nid: str
|
|
49
|
+
name: str
|
|
50
|
+
ntype: str = "state"
|
|
51
|
+
shape: str = "rectangle"
|
|
52
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
states: list[str] = field(default_factory=list)
|
|
54
|
+
custom: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ChartEdge:
|
|
59
|
+
"""
|
|
60
|
+
Data class representing an edge/connection in the chart.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
eid: Unique edge identifier
|
|
64
|
+
src: Source node ID
|
|
65
|
+
dst: Destination node ID
|
|
66
|
+
label: Edge label/condition
|
|
67
|
+
etype: Edge type (transition, event, flow)
|
|
68
|
+
bidirectional: Whether edge is bidirectional
|
|
69
|
+
"""
|
|
70
|
+
eid: str
|
|
71
|
+
src: str
|
|
72
|
+
dst: str
|
|
73
|
+
label: str = ""
|
|
74
|
+
etype: str = "transition"
|
|
75
|
+
bidirectional: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ChartLogic:
|
|
80
|
+
"""
|
|
81
|
+
Data class representing logic configuration for a node.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
node_id: Associated node ID
|
|
85
|
+
logic_type: Logic class type (Stage, Logic, etc.)
|
|
86
|
+
priority: Execution priority for Logic instances
|
|
87
|
+
decorators: List of decorators to apply
|
|
88
|
+
states: State method definitions
|
|
89
|
+
listeners: Event listener definitions
|
|
90
|
+
"""
|
|
91
|
+
node_id: str
|
|
92
|
+
logic_type: str = "Stage"
|
|
93
|
+
priority: int = 0
|
|
94
|
+
decorators: list[dict[str, Any]] = field(default_factory=list)
|
|
95
|
+
states: dict[str, str] = field(default_factory=dict)
|
|
96
|
+
listeners: list[dict[str, Any]] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class IChart(ABC):
|
|
100
|
+
"""
|
|
101
|
+
Interface for chart data conversion between qnode and qfsm.
|
|
102
|
+
|
|
103
|
+
IChart serves as a bridge between:
|
|
104
|
+
- qnode: Visual node graph representation (JSON)
|
|
105
|
+
- qfsm: Finite state machine implementation (Stage/Logic)
|
|
106
|
+
|
|
107
|
+
Implementations should handle:
|
|
108
|
+
1. Parsing qnode JSON into internal node/edge representation
|
|
109
|
+
2. Generating qfsm-compatible class definitions
|
|
110
|
+
3. Converting visual graphs to executable state machines
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
# Load from chartflow.node JSON
|
|
114
|
+
chart = MyChart()
|
|
115
|
+
chart.fromJson(json_data)
|
|
116
|
+
|
|
117
|
+
# Generate qfsm Stage class
|
|
118
|
+
stage_code = chart.toFsmStage()
|
|
119
|
+
|
|
120
|
+
# Or create runtime instance
|
|
121
|
+
stage = chart.createStage()
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# Class-level metadata
|
|
125
|
+
NAME: ClassVar[str] = "IChart"
|
|
126
|
+
VERSION: ClassVar[str] = "1.0"
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic'] | None = None
|
|
131
|
+
):
|
|
132
|
+
"""
|
|
133
|
+
Initialize chart, optionally from existing data.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
src: Source data to initialize from:
|
|
137
|
+
- dict: JSON data (qnode format or extractNodes format)
|
|
138
|
+
- Stage: Parse from Stage instance or class
|
|
139
|
+
- Logic: Parse from Logic instance or class
|
|
140
|
+
- None: Create empty chart
|
|
141
|
+
|
|
142
|
+
Note:
|
|
143
|
+
IChart is an abstract base class. Use Chart for concrete implementation.
|
|
144
|
+
"""
|
|
145
|
+
self._nodes: dict[str, ChartNode] = {}
|
|
146
|
+
self._edges: dict[str, ChartEdge] = {}
|
|
147
|
+
self._logics: dict[str, ChartLogic] = {}
|
|
148
|
+
self._metadata: dict[str, Any] = {}
|
|
149
|
+
|
|
150
|
+
if src is not None:
|
|
151
|
+
self._initFrom(src)
|
|
152
|
+
|
|
153
|
+
def _initFrom(
|
|
154
|
+
self, src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic']
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Initialize chart from various source types (to be implemented by subclasses)."""
|
|
157
|
+
raise NotImplementedError("Subclasses must implement _initFrom")
|
|
158
|
+
|
|
159
|
+
# -------------------------------------------------------------------------
|
|
160
|
+
# Properties
|
|
161
|
+
# -------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def nodes(self) -> list[ChartNode]:
|
|
165
|
+
"""Get all nodes in the chart."""
|
|
166
|
+
return list(self._nodes.values())
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def edges(self) -> list[ChartEdge]:
|
|
170
|
+
"""Get all edges in the chart."""
|
|
171
|
+
return list(self._edges.values())
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def logics(self) -> list[ChartLogic]:
|
|
175
|
+
"""Get all logic configurations."""
|
|
176
|
+
return list(self._logics.values())
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def startnode(self) -> str | None:
|
|
180
|
+
"""Get start node ID."""
|
|
181
|
+
return self._metadata.get("startnode")
|
|
182
|
+
|
|
183
|
+
@startnode.setter
|
|
184
|
+
def startnode(self, nid: str | None) -> None:
|
|
185
|
+
"""Set start node ID."""
|
|
186
|
+
self._metadata["startnode"] = nid
|
|
187
|
+
|
|
188
|
+
# -------------------------------------------------------------------------
|
|
189
|
+
# Node/Edge Management
|
|
190
|
+
# -------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def addNode(self, node: ChartNode) -> None:
|
|
193
|
+
"""Add a node to the chart."""
|
|
194
|
+
self._nodes[node.nid] = node
|
|
195
|
+
|
|
196
|
+
def removeNode(self, nid: str) -> ChartNode | None:
|
|
197
|
+
"""Remove a node and return it, or None if not found."""
|
|
198
|
+
return self._nodes.pop(nid, None)
|
|
199
|
+
|
|
200
|
+
def getNode(self, nid: str) -> ChartNode | None:
|
|
201
|
+
"""Get node by ID."""
|
|
202
|
+
return self._nodes.get(nid)
|
|
203
|
+
|
|
204
|
+
def addEdge(self, edge: ChartEdge) -> None:
|
|
205
|
+
"""Add an edge to the chart."""
|
|
206
|
+
self._edges[edge.eid] = edge
|
|
207
|
+
|
|
208
|
+
def removeEdge(self, eid: str) -> ChartEdge | None:
|
|
209
|
+
"""Remove an edge and return it, or None if not found."""
|
|
210
|
+
return self._edges.pop(eid, None)
|
|
211
|
+
|
|
212
|
+
def getEdge(self, eid: str) -> ChartEdge | None:
|
|
213
|
+
"""Get edge by ID."""
|
|
214
|
+
return self._edges.get(eid)
|
|
215
|
+
|
|
216
|
+
def addLogic(self, logic: ChartLogic) -> None:
|
|
217
|
+
"""Add logic configuration for a node."""
|
|
218
|
+
self._logics[logic.node_id] = logic
|
|
219
|
+
|
|
220
|
+
def getLogic(self, nid: str) -> ChartLogic | None:
|
|
221
|
+
"""Get logic configuration for node."""
|
|
222
|
+
return self._logics.get(nid)
|
|
223
|
+
|
|
224
|
+
# -------------------------------------------------------------------------
|
|
225
|
+
# Abstract Methods - Import/Export
|
|
226
|
+
# -------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
@abstractmethod
|
|
229
|
+
def fromJson(self, data: dict[str, Any]) -> IChart:
|
|
230
|
+
"""
|
|
231
|
+
Load chart from chartflow.node JSON format.
|
|
232
|
+
When converting to visual representation, auto-layout should be applied.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
data: qnode exported JSON as dict
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Self for chaining
|
|
239
|
+
"""
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
@abstractmethod
|
|
243
|
+
def toJson(self) -> dict[str, Any]:
|
|
244
|
+
"""
|
|
245
|
+
Export chart to qnode JSON format.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Dict compatible with qnode import
|
|
249
|
+
"""
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
@abstractmethod
|
|
253
|
+
def toStage(self, inst: Any | None = None) -> 'Stage':
|
|
254
|
+
"""
|
|
255
|
+
Create qfsm Stage instance from chart.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
inst: Optional bound instance
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
qfsm Stage instance
|
|
262
|
+
"""
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
@abstractmethod
|
|
266
|
+
def toLogic(self, inst: Any | None = None) -> 'Logic':
|
|
267
|
+
"""
|
|
268
|
+
Create qfsm Logic instance from chart.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
inst: Optional bound instance
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
qfsm Logic instance
|
|
275
|
+
"""
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
@abstractmethod
|
|
279
|
+
def fromStage(self, stage: 'Stage') -> IChart:
|
|
280
|
+
"""
|
|
281
|
+
Load chart from chartflow.fsm Stage instance.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
stage: qfsm Stage instance to parse
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Self for chaining
|
|
288
|
+
"""
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
@abstractmethod
|
|
292
|
+
def fromLogic(self, logic: 'Logic') -> IChart:
|
|
293
|
+
"""
|
|
294
|
+
Load chart from chartflow.fsm Logic instance.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
logic: qfsm Logic instance to parse
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Self for chaining
|
|
301
|
+
"""
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
@abstractmethod
|
|
305
|
+
def toImage(self, width: int = 800, height: int = 600) -> Any:
|
|
306
|
+
"""
|
|
307
|
+
Render chart to an image.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
width: Image width in pixels
|
|
311
|
+
height: Image height in pixels
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
PIL Image object
|
|
315
|
+
"""
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# -------------------------------------------------------------------------
|
|
319
|
+
# Utility Methods
|
|
320
|
+
# -------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def clearChart(self) -> None:
|
|
323
|
+
"""Clear all nodes, edges and logics."""
|
|
324
|
+
self._nodes.clear()
|
|
325
|
+
self._edges.clear()
|
|
326
|
+
self._logics.clear()
|
|
327
|
+
self._metadata.clear()
|
|
328
|
+
|
|
329
|
+
def getOutgoing(self, nid: str) -> list[ChartEdge]:
|
|
330
|
+
"""Get all outgoing edges from a node."""
|
|
331
|
+
return [e for e in self._edges.values() if e.src == nid]
|
|
332
|
+
|
|
333
|
+
def getIncoming(self, nid: str) -> list[ChartEdge]:
|
|
334
|
+
"""Get all incoming edges to a node."""
|
|
335
|
+
return [e for e in self._edges.values() if e.dst == nid]
|
|
336
|
+
|
|
337
|
+
def getConnected(self, nid: str) -> list[str]:
|
|
338
|
+
"""Get IDs of all nodes connected to given node."""
|
|
339
|
+
connected = set()
|
|
340
|
+
for edge in self._edges.values():
|
|
341
|
+
if edge.src == nid:
|
|
342
|
+
connected.add(edge.dst)
|
|
343
|
+
elif edge.dst == nid:
|
|
344
|
+
connected.add(edge.src)
|
|
345
|
+
return list(connected)
|
|
346
|
+
|
|
347
|
+
def validChart(self) -> tuple[bool, str]:
|
|
348
|
+
"""
|
|
349
|
+
Validate chart integrity.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Tuple of (is_valid, error_message)
|
|
353
|
+
"""
|
|
354
|
+
# Check start node exists
|
|
355
|
+
if self.startnode and self.startnode not in self._nodes:
|
|
356
|
+
return False, f"Start node '{self.startnode}' not found"
|
|
357
|
+
|
|
358
|
+
# Check edge endpoints exist
|
|
359
|
+
for edge in self._edges.values():
|
|
360
|
+
if edge.src not in self._nodes:
|
|
361
|
+
return False, f"Edge {edge.eid}: source '{edge.src}' not found"
|
|
362
|
+
if edge.dst not in self._nodes:
|
|
363
|
+
return False, f"Edge {edge.eid}: destination '{edge.dst}' not found"
|
|
364
|
+
|
|
365
|
+
# Check logic references exist
|
|
366
|
+
for logic in self._logics.values():
|
|
367
|
+
if logic.node_id not in self._nodes:
|
|
368
|
+
return False, f"Logic for non-existent node '{logic.node_id}'"
|
|
369
|
+
|
|
370
|
+
return True, ""
|
|
371
|
+
|
|
372
|
+
def __repr__(self) -> str:
|
|
373
|
+
return f"{self.__class__.__name__}(nodes={len(self._nodes)}, edges={len(self._edges)})"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class Chart(IChart):
|
|
377
|
+
"""
|
|
378
|
+
Chart implementation of IChart interface.
|
|
379
|
+
|
|
380
|
+
Provides bidirectional conversion between:
|
|
381
|
+
- qnode JSON format (visual node graph)
|
|
382
|
+
- qfsm Stage/Logic instances (executable state machines)
|
|
383
|
+
|
|
384
|
+
Example:
|
|
385
|
+
# Load from chartflow.node JSON
|
|
386
|
+
chart = Chart()
|
|
387
|
+
chart.fromJson(json_data)
|
|
388
|
+
|
|
389
|
+
# Convert to Stage instance
|
|
390
|
+
stage = chart.toStage()
|
|
391
|
+
|
|
392
|
+
# Or convert from existing Stage
|
|
393
|
+
chart2 = Chart()
|
|
394
|
+
chart2.fromStage(existing_stage)
|
|
395
|
+
json_data = chart2.toJson()
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
NAME = "Chart"
|
|
399
|
+
VERSION = "1.0"
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic'] | None = None
|
|
404
|
+
):
|
|
405
|
+
"""
|
|
406
|
+
Initialize Chart, optionally from existing data.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
src: Source data to initialize from:
|
|
410
|
+
- dict: JSON data (qnode format or extractNodes format)
|
|
411
|
+
- Stage: Parse from Stage instance or class
|
|
412
|
+
- Logic: Parse from Logic instance or class
|
|
413
|
+
- None: Create empty chart
|
|
414
|
+
|
|
415
|
+
Example:
|
|
416
|
+
# From JSON
|
|
417
|
+
chart = Chart(json_data)
|
|
418
|
+
|
|
419
|
+
# From Stage instance
|
|
420
|
+
chart = Chart(my_stage)
|
|
421
|
+
|
|
422
|
+
# From Stage class (no instantiation)
|
|
423
|
+
chart = Chart(MyStage)
|
|
424
|
+
|
|
425
|
+
# From Logic instance
|
|
426
|
+
chart = Chart(my_logic)
|
|
427
|
+
|
|
428
|
+
# From Logic class (no instantiation)
|
|
429
|
+
chart = Chart(MyLogic)
|
|
430
|
+
|
|
431
|
+
# Empty
|
|
432
|
+
chart = Chart()
|
|
433
|
+
"""
|
|
434
|
+
super().__init__(None) # Don't init from src in parent, handle here
|
|
435
|
+
self._stageClass: Type['Stage'] | None = None
|
|
436
|
+
self._logicClass: Type['Logic'] | None = None
|
|
437
|
+
|
|
438
|
+
if src is not None:
|
|
439
|
+
self._initFrom(src)
|
|
440
|
+
|
|
441
|
+
def _initFrom(
|
|
442
|
+
self, src: dict[str, Any] | 'Stage' | Type['Stage'] | 'Logic' | Type['Logic']
|
|
443
|
+
) -> None:
|
|
444
|
+
"""Initialize chart from various source types."""
|
|
445
|
+
if isinstance(src, dict):
|
|
446
|
+
self.fromJson(src)
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# Check if it's a Logic (instance or class)
|
|
451
|
+
if isinstance(src, Logic) or (isinstance(src, type) and issubclass(src, Logic)):
|
|
452
|
+
self.fromLogic(src)
|
|
453
|
+
# Check if it's a Stage (instance or class)
|
|
454
|
+
elif isinstance(src, Stage) or (isinstance(src, type) and issubclass(src, Stage)):
|
|
455
|
+
self.fromStage(src)
|
|
456
|
+
else:
|
|
457
|
+
raise TypeError(f"Unsupported source type: {type(src).__name__}")
|
|
458
|
+
|
|
459
|
+
def fromJson(self, data: dict[str, Any]) -> 'Chart':
|
|
460
|
+
"""
|
|
461
|
+
Load chart from JSON format.
|
|
462
|
+
|
|
463
|
+
Supports two formats:
|
|
464
|
+
1. Standard qnode JSON: {"nodes": [...], "connections": [...]}
|
|
465
|
+
2. extractNodes format: {state_name: {code, targets, decorates}}
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
data: JSON data as dict
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Self for chaining
|
|
472
|
+
"""
|
|
473
|
+
self.clearChart()
|
|
474
|
+
|
|
475
|
+
# Detect format: extractNodes format has no "nodes" key
|
|
476
|
+
if "nodes" not in data and "connections" not in data:
|
|
477
|
+
self._fromExtractNodesFormat(data)
|
|
478
|
+
return self
|
|
479
|
+
|
|
480
|
+
# Standard qnode JSON format
|
|
481
|
+
# Load start node
|
|
482
|
+
if "start_node_id" in data:
|
|
483
|
+
start_id = data["start_node_id"]
|
|
484
|
+
if start_id is not None:
|
|
485
|
+
self.startnode = str(start_id)
|
|
486
|
+
|
|
487
|
+
# Load nodes
|
|
488
|
+
for node_data in data.get("nodes", []):
|
|
489
|
+
node = ChartNode(
|
|
490
|
+
nid=str(node_data["id"]),
|
|
491
|
+
name=node_data.get("name", ""),
|
|
492
|
+
ntype=node_data.get("type", "state"),
|
|
493
|
+
shape=node_data.get("shape", "rectangle").lower(),
|
|
494
|
+
data=node_data.get("data", {}),
|
|
495
|
+
states=node_data.get("data", {}).get("states", []),
|
|
496
|
+
custom=node_data.get("custom", {})
|
|
497
|
+
)
|
|
498
|
+
self.addNode(node)
|
|
499
|
+
|
|
500
|
+
# Load connections as edges
|
|
501
|
+
for conn_data in data.get("connections", []):
|
|
502
|
+
edge = ChartEdge(
|
|
503
|
+
eid=f"edge_{conn_data['source_id']}_{conn_data['target_id']}",
|
|
504
|
+
src=str(conn_data["source_id"]),
|
|
505
|
+
dst=str(conn_data["target_id"]),
|
|
506
|
+
label=conn_data.get("label", ""),
|
|
507
|
+
etype=conn_data.get("connection_type", "transition").lower(),
|
|
508
|
+
bidirectional=conn_data.get("is_bidirectional", False)
|
|
509
|
+
)
|
|
510
|
+
self.addEdge(edge)
|
|
511
|
+
|
|
512
|
+
return self
|
|
513
|
+
|
|
514
|
+
def _fromExtractNodesFormat(self, data: dict[str, Any]) -> None:
|
|
515
|
+
"""
|
|
516
|
+
Load from extractNodes() format: {state_name: {code, targets, decorates}}.
|
|
517
|
+
|
|
518
|
+
Compatible with old _importDict() behavior.
|
|
519
|
+
"""
|
|
520
|
+
node_map = {} # name -> nid
|
|
521
|
+
|
|
522
|
+
# Create nodes
|
|
523
|
+
for i, (state_name, info) in enumerate(data.items()):
|
|
524
|
+
nid = f"state_{i}"
|
|
525
|
+
node_map[state_name] = nid
|
|
526
|
+
|
|
527
|
+
node_data = {
|
|
528
|
+
"code": info.get("code", ""),
|
|
529
|
+
"targets": info.get("targets", []),
|
|
530
|
+
"decorates": info.get("decorates", [])
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
node = ChartNode(
|
|
534
|
+
nid=nid,
|
|
535
|
+
name=state_name,
|
|
536
|
+
ntype="state",
|
|
537
|
+
shape="ellipse",
|
|
538
|
+
data=node_data
|
|
539
|
+
)
|
|
540
|
+
self.addNode(node)
|
|
541
|
+
|
|
542
|
+
# Create edges from targets
|
|
543
|
+
for state_name, info in data.items():
|
|
544
|
+
src_nid = node_map[state_name]
|
|
545
|
+
for target in info.get("targets", []):
|
|
546
|
+
if target in node_map:
|
|
547
|
+
dst_nid = node_map[target]
|
|
548
|
+
edge = ChartEdge(
|
|
549
|
+
eid=f"edge_{state_name}_{target}",
|
|
550
|
+
src=src_nid,
|
|
551
|
+
dst=dst_nid,
|
|
552
|
+
label=f"{state_name}->{target}",
|
|
553
|
+
etype="transition"
|
|
554
|
+
)
|
|
555
|
+
self.addEdge(edge)
|
|
556
|
+
|
|
557
|
+
# Set start node
|
|
558
|
+
if data:
|
|
559
|
+
self.startnode = "state_0"
|
|
560
|
+
|
|
561
|
+
def toJson(self) -> dict[str, Any]:
|
|
562
|
+
"""
|
|
563
|
+
Export chart to qnode JSON format.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Dict compatible with qnode import
|
|
567
|
+
"""
|
|
568
|
+
nodes = []
|
|
569
|
+
for node in self._nodes.values():
|
|
570
|
+
node_data = {
|
|
571
|
+
"id": node.nid,
|
|
572
|
+
"name": node.name,
|
|
573
|
+
"shape": node.shape.upper(),
|
|
574
|
+
"type": node.ntype,
|
|
575
|
+
"data": node.data.copy()
|
|
576
|
+
}
|
|
577
|
+
# 添加 custom 标记配置(如果有)
|
|
578
|
+
if node.custom:
|
|
579
|
+
node_data["custom"] = node.custom.copy()
|
|
580
|
+
nodes.append(node_data)
|
|
581
|
+
|
|
582
|
+
connections = []
|
|
583
|
+
for edge in self._edges.values():
|
|
584
|
+
conn_data = {
|
|
585
|
+
"source_id": edge.src,
|
|
586
|
+
"target_id": edge.dst,
|
|
587
|
+
"label": edge.label,
|
|
588
|
+
"is_bidirectional": edge.bidirectional,
|
|
589
|
+
"connection_type": edge.etype.upper()
|
|
590
|
+
}
|
|
591
|
+
connections.append(conn_data)
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
"nodes": nodes,
|
|
595
|
+
"connections": connections,
|
|
596
|
+
"start_node_id": self.startnode
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
def toStage(self, inst: Any | None = None) -> 'Stage':
|
|
600
|
+
"""
|
|
601
|
+
Create qfsm Stage instance from chart.
|
|
602
|
+
|
|
603
|
+
Dynamically creates a Stage subclass based on the chart structure
|
|
604
|
+
and instantiates it.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
inst: Optional bound instance
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
qfsm Stage instance
|
|
611
|
+
"""
|
|
612
|
+
if self._stageClass is None:
|
|
613
|
+
self._stageClass = self._createStageClass()
|
|
614
|
+
return self._stageClass(inst)
|
|
615
|
+
|
|
616
|
+
def toLogic(self, inst: Any | None = None) -> 'Logic':
|
|
617
|
+
"""
|
|
618
|
+
Create qfsm Logic instance from chart.
|
|
619
|
+
|
|
620
|
+
Dynamically creates a Logic subclass based on the chart structure
|
|
621
|
+
and instantiates it.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
inst: Optional bound instance
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
qfsm Logic instance
|
|
628
|
+
"""
|
|
629
|
+
if self._logicClass is None:
|
|
630
|
+
self._logicClass = self._createLogicClass()
|
|
631
|
+
return self._logicClass(inst)
|
|
632
|
+
|
|
633
|
+
def fromStage(self, src: 'Stage' | Type['Stage']) -> 'Chart':
|
|
634
|
+
"""
|
|
635
|
+
Load chart from chartflow.fsm Stage instance or class.
|
|
636
|
+
|
|
637
|
+
Parses the Stage's states, code, targets, and decorates into nodes and edges.
|
|
638
|
+
Output format compatible with extractNodes().
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
src: qfsm Stage instance or class to parse
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
Self for chaining
|
|
645
|
+
|
|
646
|
+
Example:
|
|
647
|
+
# From instance
|
|
648
|
+
chart = Chart(my_stage)
|
|
649
|
+
|
|
650
|
+
# From class (without instantiation)
|
|
651
|
+
chart = Chart(MyStage)
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
self.clearChart()
|
|
655
|
+
|
|
656
|
+
# Handle class vs instance
|
|
657
|
+
if isinstance(src, type):
|
|
658
|
+
stage_class = src
|
|
659
|
+
stage_instance = None
|
|
660
|
+
else:
|
|
661
|
+
stage_class = src.__class__
|
|
662
|
+
stage_instance = src
|
|
663
|
+
|
|
664
|
+
states = getattr(stage_class, "__states__", [])
|
|
665
|
+
|
|
666
|
+
# Get source code for AST analysis
|
|
667
|
+
try:
|
|
668
|
+
source_file = inspect.getsourcefile(stage_class)
|
|
669
|
+
if source_file:
|
|
670
|
+
with open(source_file, 'r', encoding='utf-8') as f:
|
|
671
|
+
source = f.read()
|
|
672
|
+
module_ast = ast.parse(source)
|
|
673
|
+
|
|
674
|
+
# Find class definition
|
|
675
|
+
class_node = None
|
|
676
|
+
for node in ast.walk(module_ast):
|
|
677
|
+
if isinstance(node, ast.ClassDef) and node.name == stage_class.__name__:
|
|
678
|
+
class_node = node
|
|
679
|
+
break
|
|
680
|
+
else:
|
|
681
|
+
class_node = None
|
|
682
|
+
source = ""
|
|
683
|
+
except Exception:
|
|
684
|
+
class_node = None
|
|
685
|
+
source = ""
|
|
686
|
+
|
|
687
|
+
# Build state -> method node mapping
|
|
688
|
+
method_nodes = {}
|
|
689
|
+
if class_node:
|
|
690
|
+
for node in class_node.body:
|
|
691
|
+
if isinstance(node, ast.FunctionDef) and node.name in states:
|
|
692
|
+
method_nodes[node.name] = node
|
|
693
|
+
|
|
694
|
+
# Create node for each state with full data
|
|
695
|
+
for i, state_name in enumerate(states):
|
|
696
|
+
node_data = {"is_recursive": state_name in getattr(stage_class, "__recursive__", set())}
|
|
697
|
+
|
|
698
|
+
# Extract code, targets, decorates from AST
|
|
699
|
+
func_node = method_nodes.get(state_name)
|
|
700
|
+
if func_node and source:
|
|
701
|
+
# Extract function body code
|
|
702
|
+
node_data["code"] = self._extractFunctionBody(source, func_node)
|
|
703
|
+
|
|
704
|
+
# Extract targets (return string constants)
|
|
705
|
+
node_data["targets"] = self._extractStaticTargets(func_node, state_name)
|
|
706
|
+
|
|
707
|
+
# Extract decorates (excluding property)
|
|
708
|
+
node_data["decorates"] = self._extractDecorators(func_node)
|
|
709
|
+
|
|
710
|
+
node = ChartNode(
|
|
711
|
+
nid=f"state_{i}",
|
|
712
|
+
name=state_name,
|
|
713
|
+
ntype="state",
|
|
714
|
+
shape="ellipse",
|
|
715
|
+
data=node_data
|
|
716
|
+
)
|
|
717
|
+
self.addNode(node)
|
|
718
|
+
|
|
719
|
+
# Create edges from targets
|
|
720
|
+
for target in node_data.get("targets", []):
|
|
721
|
+
if target in states:
|
|
722
|
+
edge = ChartEdge(
|
|
723
|
+
eid=f"edge_{state_name}_{target}",
|
|
724
|
+
src=f"state_{i}",
|
|
725
|
+
dst=f"state_{states.index(target)}",
|
|
726
|
+
label=f"{state_name}->{target}",
|
|
727
|
+
etype="transition"
|
|
728
|
+
)
|
|
729
|
+
self.addEdge(edge)
|
|
730
|
+
|
|
731
|
+
# Set start node to first state if exists
|
|
732
|
+
if states:
|
|
733
|
+
self.startnode = "state_0"
|
|
734
|
+
|
|
735
|
+
return self
|
|
736
|
+
|
|
737
|
+
def fromLogic(self, src: 'Logic' | Type['Logic']) -> 'Chart':
|
|
738
|
+
"""
|
|
739
|
+
Load chart from chartflow.fsm Logic instance or class.
|
|
740
|
+
|
|
741
|
+
Parses the Logic's states and properties into nodes and logic config.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
src: qfsm Logic instance or class to parse
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Self for chaining
|
|
748
|
+
|
|
749
|
+
Example:
|
|
750
|
+
# From instance
|
|
751
|
+
chart = Chart(my_logic)
|
|
752
|
+
|
|
753
|
+
# From class (without instantiation)
|
|
754
|
+
chart = Chart(MyLogic)
|
|
755
|
+
"""
|
|
756
|
+
# Handle class vs instance
|
|
757
|
+
if isinstance(src, type):
|
|
758
|
+
logic_class = src
|
|
759
|
+
else:
|
|
760
|
+
logic_class = src.__class__
|
|
761
|
+
|
|
762
|
+
self.fromStage(src)
|
|
763
|
+
|
|
764
|
+
# Add logic-specific metadata
|
|
765
|
+
for node in self._nodes.values():
|
|
766
|
+
node.data["priority"] = getattr(logic_class, "PRIORITY", 0)
|
|
767
|
+
node.data["logic_name"] = getattr(logic_class, "NAME", "Logic")
|
|
768
|
+
|
|
769
|
+
return self
|
|
770
|
+
|
|
771
|
+
def _createStageClass(self) -> Type['Stage']:
|
|
772
|
+
"""
|
|
773
|
+
Dynamically create a Stage subclass from chart nodes/edges.
|
|
774
|
+
|
|
775
|
+
Returns:
|
|
776
|
+
New Stage subclass
|
|
777
|
+
"""
|
|
778
|
+
# Build state methods from nodes
|
|
779
|
+
methods: dict[str, Any] = {}
|
|
780
|
+
|
|
781
|
+
for node in self._nodes.values():
|
|
782
|
+
if node.ntype == "state":
|
|
783
|
+
# Find outgoing transitions for this state
|
|
784
|
+
outgoing = self.getOutgoing(node.nid)
|
|
785
|
+
if outgoing:
|
|
786
|
+
# Create state method that returns target state
|
|
787
|
+
target_edge = outgoing[0]
|
|
788
|
+
target_node = self._nodes.get(target_edge.dst)
|
|
789
|
+
if target_node:
|
|
790
|
+
def makeStateMethod(target: str):
|
|
791
|
+
def stateMethod(self, inst: Any) -> str | None:
|
|
792
|
+
return target
|
|
793
|
+
return stateMethod
|
|
794
|
+
methods[node.name] = makeStateMethod(target_node.name)
|
|
795
|
+
else:
|
|
796
|
+
# Terminal state
|
|
797
|
+
def makeTerminalState():
|
|
798
|
+
def terminalState(self, inst: Any) -> None:
|
|
799
|
+
return None
|
|
800
|
+
return terminalState
|
|
801
|
+
methods[node.name] = makeTerminalState()
|
|
802
|
+
|
|
803
|
+
# Create the class
|
|
804
|
+
class_name = "ChartStage"
|
|
805
|
+
return type(
|
|
806
|
+
class_name,
|
|
807
|
+
(Stage,),
|
|
808
|
+
{
|
|
809
|
+
"NAME": class_name,
|
|
810
|
+
**methods
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
def _createLogicClass(self) -> Type['Logic']:
|
|
815
|
+
"""
|
|
816
|
+
Dynamically create a Logic subclass from chart nodes/edges.
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
New Logic subclass
|
|
820
|
+
"""
|
|
821
|
+
# Get base Stage class methods
|
|
822
|
+
stage_class = self._createStageClass()
|
|
823
|
+
|
|
824
|
+
# Extract methods from stage class (excluding __states__ etc)
|
|
825
|
+
methods = {}
|
|
826
|
+
for attr_name in dir(stage_class):
|
|
827
|
+
if not attr_name.startswith("__") and not attr_name.endswith("__"):
|
|
828
|
+
attr = getattr(stage_class, attr_name)
|
|
829
|
+
if callable(attr) and not isinstance(attr, type):
|
|
830
|
+
methods[attr_name] = attr
|
|
831
|
+
|
|
832
|
+
# Create Logic subclass
|
|
833
|
+
class_name = "ChartLogic"
|
|
834
|
+
return type(
|
|
835
|
+
class_name,
|
|
836
|
+
(Logic,),
|
|
837
|
+
{
|
|
838
|
+
"NAME": class_name,
|
|
839
|
+
"PRIORITY": 0,
|
|
840
|
+
**methods
|
|
841
|
+
}
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
def _extractFunctionBody(self, source: str, func_node: Any) -> str:
|
|
845
|
+
"""Extract function body from source, removing def line and common indent."""
|
|
846
|
+
start_line = func_node.lineno
|
|
847
|
+
end_line = getattr(func_node, 'end_lineno', start_line)
|
|
848
|
+
|
|
849
|
+
lines = source.splitlines()
|
|
850
|
+
if start_line > len(lines) or end_line > len(lines):
|
|
851
|
+
return ""
|
|
852
|
+
|
|
853
|
+
func_lines = lines[start_line - 1:end_line]
|
|
854
|
+
body_lines = func_lines[1:] # Remove def line
|
|
855
|
+
|
|
856
|
+
if not body_lines:
|
|
857
|
+
return ""
|
|
858
|
+
|
|
859
|
+
# Calculate common indent
|
|
860
|
+
first_body = body_lines[0]
|
|
861
|
+
indent = len(first_body) - len(first_body.lstrip())
|
|
862
|
+
|
|
863
|
+
cleaned = []
|
|
864
|
+
for line in body_lines:
|
|
865
|
+
if line.strip() == "":
|
|
866
|
+
cleaned.append("")
|
|
867
|
+
elif len(line) >= indent:
|
|
868
|
+
cleaned.append(line[indent:])
|
|
869
|
+
else:
|
|
870
|
+
cleaned.append(line)
|
|
871
|
+
|
|
872
|
+
return "\n".join(cleaned).rstrip()
|
|
873
|
+
|
|
874
|
+
def _extractStaticTargets(self, func_node: Any, current_state: str) -> list[str]:
|
|
875
|
+
"""Extract return string constants as targets."""
|
|
876
|
+
targets = set()
|
|
877
|
+
|
|
878
|
+
def _visit(node):
|
|
879
|
+
if isinstance(node, ast.Return) and node.value:
|
|
880
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
881
|
+
if node.value.value != current_state:
|
|
882
|
+
targets.add(node.value.value)
|
|
883
|
+
elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
|
|
884
|
+
if node.value.s != current_state:
|
|
885
|
+
targets.add(node.value.s)
|
|
886
|
+
elif isinstance(node, (ast.If, ast.IfExp)):
|
|
887
|
+
for child in ast.iter_child_nodes(node):
|
|
888
|
+
_visit(child)
|
|
889
|
+
else:
|
|
890
|
+
for child in ast.iter_child_nodes(node):
|
|
891
|
+
_visit(child)
|
|
892
|
+
|
|
893
|
+
for stmt in func_node.body:
|
|
894
|
+
_visit(stmt)
|
|
895
|
+
|
|
896
|
+
return sorted(list(targets))
|
|
897
|
+
|
|
898
|
+
def _extractDecorators(self, func_node: Any) -> list[str]:
|
|
899
|
+
"""Extract decorator names (excluding property)."""
|
|
900
|
+
decorates = []
|
|
901
|
+
for dec in func_node.decorator_list:
|
|
902
|
+
# Skip property decorator
|
|
903
|
+
if isinstance(dec, ast.Name) and dec.id == 'property':
|
|
904
|
+
continue
|
|
905
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == 'property':
|
|
906
|
+
continue
|
|
907
|
+
|
|
908
|
+
# Get decorator name
|
|
909
|
+
name = self._getDecoratorName(dec)
|
|
910
|
+
if name:
|
|
911
|
+
decorates.append(name)
|
|
912
|
+
return decorates
|
|
913
|
+
|
|
914
|
+
def _getDecoratorName(self, decorator: Any) -> str | None:
|
|
915
|
+
"""Get decorator name from AST node."""
|
|
916
|
+
if isinstance(decorator, ast.Name):
|
|
917
|
+
return decorator.id
|
|
918
|
+
elif isinstance(decorator, ast.Attribute):
|
|
919
|
+
return decorator.attr
|
|
920
|
+
elif isinstance(decorator, ast.Call):
|
|
921
|
+
if isinstance(decorator.func, ast.Name):
|
|
922
|
+
return decorator.func.id
|
|
923
|
+
elif isinstance(decorator.func, ast.Attribute):
|
|
924
|
+
return decorator.func.attr
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
def toImage(self, width: int = 1200, height: int = 800) -> Any:
|
|
928
|
+
"""
|
|
929
|
+
Render chart to a PNG image using qnode's PyQt6 components.
|
|
930
|
+
|
|
931
|
+
Uses off-screen rendering with NodeCanvas for high-quality output.
|
|
932
|
+
|
|
933
|
+
Args:
|
|
934
|
+
width: Image width in pixels
|
|
935
|
+
height: Image height in pixels
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
PIL Image object
|
|
939
|
+
"""
|
|
940
|
+
|
|
941
|
+
# Create QApplication if needed (for off-screen rendering)
|
|
942
|
+
app = QApplication.instance()
|
|
943
|
+
if app is None:
|
|
944
|
+
app = QApplication([])
|
|
945
|
+
|
|
946
|
+
# Create canvas in off-screen mode
|
|
947
|
+
canvas = QNodeCanvas()
|
|
948
|
+
canvas.setRenderHints(
|
|
949
|
+
QPainter.RenderHint.Antialiasing |
|
|
950
|
+
QPainter.RenderHint.SmoothPixmapTransform |
|
|
951
|
+
QPainter.RenderHint.TextAntialiasing
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
# Prepare export data
|
|
955
|
+
export_data = self.toJson()
|
|
956
|
+
|
|
957
|
+
# Map shape strings to NodeShape
|
|
958
|
+
shape_map = {
|
|
959
|
+
"ellipse": NodeShape.ELLIPSE,
|
|
960
|
+
"rectangle": NodeShape.RECTANGLE,
|
|
961
|
+
"diamond": NodeShape.DIAMOND
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
# Create node mapping
|
|
965
|
+
node_map = {}
|
|
966
|
+
|
|
967
|
+
# Add nodes with random initial positions (layout will fix them)
|
|
968
|
+
for node_data in export_data.get("nodes", []):
|
|
969
|
+
nid = node_data["id"]
|
|
970
|
+
name = node_data.get("name", "Unnamed")
|
|
971
|
+
shape_name = node_data.get("shape", "ELLIPSE").lower()
|
|
972
|
+
shape = shape_map.get(shape_name, NodeShape.ELLIPSE)
|
|
973
|
+
|
|
974
|
+
# Random initial position (will be adjusted by layout)
|
|
975
|
+
pos = QPointF(random.randint(-400, 400), random.randint(-300, 300))
|
|
976
|
+
|
|
977
|
+
node = canvas.addNode(name, pos, shape)
|
|
978
|
+
node_data_dict = node_data.get("data", {})
|
|
979
|
+
node.data.update(node_data_dict)
|
|
980
|
+
|
|
981
|
+
# 同步装饰器信息到节点的 decorator 属性
|
|
982
|
+
# 支持 data["decorates"] (来自 Stage/Logic 解析) 或 data["decorator"]
|
|
983
|
+
decorates = node_data_dict.get("decorates", [])
|
|
984
|
+
if decorates:
|
|
985
|
+
# 取第一个装饰器作为节点的主要装饰器
|
|
986
|
+
# 支持格式: ["recursive"] 或 [{"type": "recursive"}]
|
|
987
|
+
first_decorator = decorates[0]
|
|
988
|
+
if isinstance(first_decorator, dict):
|
|
989
|
+
node.decorator = first_decorator.get("type", "")
|
|
990
|
+
elif isinstance(first_decorator, str):
|
|
991
|
+
node.decorator = first_decorator
|
|
992
|
+
elif "decorator" in node_data_dict:
|
|
993
|
+
node.decorator = node_data_dict["decorator"]
|
|
994
|
+
|
|
995
|
+
# 同步 custom 标记配置
|
|
996
|
+
custom_data = node_data.get("custom", {})
|
|
997
|
+
if custom_data:
|
|
998
|
+
|
|
999
|
+
def parseColor(value):
|
|
1000
|
+
if isinstance(value, str):
|
|
1001
|
+
return QColor(value)
|
|
1002
|
+
return None
|
|
1003
|
+
|
|
1004
|
+
node.setCustomMarkerStyle(
|
|
1005
|
+
text=custom_data.get("text"),
|
|
1006
|
+
bg_color=parseColor(custom_data.get("bg_color")),
|
|
1007
|
+
fg_color=parseColor(custom_data.get("fg_color")),
|
|
1008
|
+
border_color=parseColor(custom_data.get("border_color")),
|
|
1009
|
+
border_width=custom_data.get("border_width"),
|
|
1010
|
+
radius=custom_data.get("radius")
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
node_map[nid] = node
|
|
1014
|
+
|
|
1015
|
+
# Add connections
|
|
1016
|
+
for conn_data in export_data.get("connections", []):
|
|
1017
|
+
src_id = conn_data.get("source_id")
|
|
1018
|
+
dst_id = conn_data.get("target_id")
|
|
1019
|
+
|
|
1020
|
+
if src_id in node_map and dst_id in node_map:
|
|
1021
|
+
is_bidir = conn_data.get("is_bidirectional", False)
|
|
1022
|
+
conn_type = ConnectionType.BEZIER # Always use bezier for nice curves
|
|
1023
|
+
|
|
1024
|
+
conn = canvas.addConnection(
|
|
1025
|
+
node_map[src_id], node_map[dst_id],
|
|
1026
|
+
is_bidirectional=is_bidir,
|
|
1027
|
+
connection_type=conn_type
|
|
1028
|
+
)
|
|
1029
|
+
# Set label if available
|
|
1030
|
+
label = conn_data.get("label", "")
|
|
1031
|
+
if label and hasattr(conn, '_label_item'):
|
|
1032
|
+
conn._label_item.setPlainText(label)
|
|
1033
|
+
|
|
1034
|
+
# Apply auto layout
|
|
1035
|
+
if len(node_map) > 0:
|
|
1036
|
+
engine = DenseLayoutEngine(canvas)
|
|
1037
|
+
engine.autoLayout("compact")
|
|
1038
|
+
|
|
1039
|
+
# Fit to viewport with padding
|
|
1040
|
+
fitter = ViewportFitter(canvas)
|
|
1041
|
+
fitter.fitViewport(margin=50)
|
|
1042
|
+
|
|
1043
|
+
# Get scene bounding rect after layout
|
|
1044
|
+
scene_rect = canvas._scene.itemsBoundingRect()
|
|
1045
|
+
scene_rect = scene_rect.adjusted(-40, -40, 40, 40) # Add padding
|
|
1046
|
+
|
|
1047
|
+
# Calculate scale to fit scene into output image while maintaining aspect ratio
|
|
1048
|
+
scale_x = width / scene_rect.width()
|
|
1049
|
+
scale_y = height / scene_rect.height()
|
|
1050
|
+
scale = min(scale_x, scale_y) * 0.9 # 0.9 factor for some margin
|
|
1051
|
+
|
|
1052
|
+
# Calculate the rendered scene size
|
|
1053
|
+
render_width = scene_rect.width() * scale
|
|
1054
|
+
render_height = scene_rect.height() * scale
|
|
1055
|
+
|
|
1056
|
+
# Center the rendered scene in the output image
|
|
1057
|
+
offset_x = (width - render_width) / 2
|
|
1058
|
+
offset_y = (height - render_height) / 2
|
|
1059
|
+
|
|
1060
|
+
# Create target rect (centered in output image)
|
|
1061
|
+
target_rect = QRectF(offset_x, offset_y, render_width, render_height)
|
|
1062
|
+
|
|
1063
|
+
# Create pixmap for rendering
|
|
1064
|
+
pixmap = QPixmap(width, height)
|
|
1065
|
+
pixmap.fill(QColor(248, 250, 252)) # Light background
|
|
1066
|
+
|
|
1067
|
+
# Render scene to pixmap
|
|
1068
|
+
painter = QPainter(pixmap)
|
|
1069
|
+
painter.setRenderHints(
|
|
1070
|
+
QPainter.RenderHint.Antialiasing |
|
|
1071
|
+
QPainter.RenderHint.SmoothPixmapTransform |
|
|
1072
|
+
QPainter.RenderHint.TextAntialiasing
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
# Render scene centered in the output image
|
|
1076
|
+
canvas._scene.render(
|
|
1077
|
+
painter,
|
|
1078
|
+
target_rect,
|
|
1079
|
+
scene_rect,
|
|
1080
|
+
Qt.AspectRatioMode.KeepAspectRatio
|
|
1081
|
+
)
|
|
1082
|
+
painter.end()
|
|
1083
|
+
|
|
1084
|
+
# Convert QPixmap to PIL Image
|
|
1085
|
+
# Save to temp file (QPixmap doesn't support BytesIO directly in PyQt6)
|
|
1086
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
1087
|
+
tmp_path = tmp.name
|
|
1088
|
+
|
|
1089
|
+
pixmap.save(tmp_path, "PNG")
|
|
1090
|
+
|
|
1091
|
+
# Open with PIL and copy to memory (to allow temp file deletion on Windows)
|
|
1092
|
+
with Image.open(tmp_path) as img_temp:
|
|
1093
|
+
img = img_temp.copy()
|
|
1094
|
+
|
|
1095
|
+
# Clean up temp file
|
|
1096
|
+
try:
|
|
1097
|
+
os.unlink(tmp_path)
|
|
1098
|
+
except PermissionError:
|
|
1099
|
+
pass # File may still be locked on Windows
|
|
1100
|
+
|
|
1101
|
+
return img
|