retroflow 0.8.2__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.
- retroflow/__init__.py +48 -0
- retroflow/generator.py +1803 -0
- retroflow/layout.py +239 -0
- retroflow/parser.py +99 -0
- retroflow/py.typed +0 -0
- retroflow/renderer.py +486 -0
- retroflow/router.py +343 -0
- retroflow-0.8.2.dist-info/METADATA +445 -0
- retroflow-0.8.2.dist-info/RECORD +11 -0
- retroflow-0.8.2.dist-info/WHEEL +4 -0
- retroflow-0.8.2.dist-info/licenses/LICENSE +21 -0
retroflow/router.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Edge routing module for flowchart generation.
|
|
3
|
+
|
|
4
|
+
Handles orthogonal routing of edges between boxes with:
|
|
5
|
+
- Port assignment (where edges connect to boxes)
|
|
6
|
+
- Non-overlapping edge paths
|
|
7
|
+
- Minimal crossings
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PortSide(Enum):
|
|
16
|
+
"""Which side of a box a port is on."""
|
|
17
|
+
|
|
18
|
+
TOP = "top"
|
|
19
|
+
BOTTOM = "bottom"
|
|
20
|
+
LEFT = "left"
|
|
21
|
+
RIGHT = "right"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Port:
|
|
26
|
+
"""A connection point on a box."""
|
|
27
|
+
|
|
28
|
+
node: str
|
|
29
|
+
side: PortSide
|
|
30
|
+
offset: int # Offset from left/top of box
|
|
31
|
+
x: int = 0 # Absolute x coordinate
|
|
32
|
+
y: int = 0 # Absolute y coordinate
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class BoxInfo:
|
|
37
|
+
"""Information about a rendered box."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
x: int
|
|
41
|
+
y: int
|
|
42
|
+
width: int
|
|
43
|
+
height: int
|
|
44
|
+
layer: int
|
|
45
|
+
position: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class EdgeRoute:
|
|
50
|
+
"""A routed edge between two boxes."""
|
|
51
|
+
|
|
52
|
+
source: str
|
|
53
|
+
target: str
|
|
54
|
+
source_port: Port
|
|
55
|
+
target_port: Port
|
|
56
|
+
waypoints: List[Tuple[int, int]] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EdgeRouter:
|
|
60
|
+
"""
|
|
61
|
+
Routes edges between boxes using orthogonal paths.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self.boxes: Dict[str, BoxInfo] = {}
|
|
66
|
+
self.used_ports: Dict[str, Dict[PortSide, Set[int]]] = {}
|
|
67
|
+
|
|
68
|
+
def set_boxes(self, boxes: Dict[str, BoxInfo]) -> None:
|
|
69
|
+
"""Set the box information for routing."""
|
|
70
|
+
self.boxes = boxes
|
|
71
|
+
self.used_ports = {name: {side: set() for side in PortSide} for name in boxes}
|
|
72
|
+
|
|
73
|
+
def route_edges(
|
|
74
|
+
self, edges: List[Tuple[str, str]], layers: List[List[str]]
|
|
75
|
+
) -> List[EdgeRoute]:
|
|
76
|
+
"""
|
|
77
|
+
Route all edges between boxes.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
edges: List of (source, target) node pairs
|
|
81
|
+
layers: List of layers, each containing node names
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of EdgeRoute objects with routing information
|
|
85
|
+
"""
|
|
86
|
+
routes: List[EdgeRoute] = []
|
|
87
|
+
|
|
88
|
+
# Build layer lookup
|
|
89
|
+
node_layer = {}
|
|
90
|
+
node_position = {}
|
|
91
|
+
for layer_idx, layer in enumerate(layers):
|
|
92
|
+
for pos_idx, node in enumerate(layer):
|
|
93
|
+
node_layer[node] = layer_idx
|
|
94
|
+
node_position[node] = pos_idx
|
|
95
|
+
|
|
96
|
+
# Group edges by source for port allocation
|
|
97
|
+
edges_by_source: Dict[str, List[str]] = {}
|
|
98
|
+
edges_by_target: Dict[str, List[str]] = {}
|
|
99
|
+
|
|
100
|
+
for source, target in edges:
|
|
101
|
+
if source not in edges_by_source:
|
|
102
|
+
edges_by_source[source] = []
|
|
103
|
+
edges_by_source[source].append(target)
|
|
104
|
+
|
|
105
|
+
if target not in edges_by_target:
|
|
106
|
+
edges_by_target[target] = []
|
|
107
|
+
edges_by_target[target].append(source)
|
|
108
|
+
|
|
109
|
+
# Sort edges by target position for consistent port allocation
|
|
110
|
+
for source in edges_by_source:
|
|
111
|
+
edges_by_source[source].sort(key=lambda t: node_position.get(t, 0))
|
|
112
|
+
|
|
113
|
+
for target in edges_by_target:
|
|
114
|
+
edges_by_target[target].sort(key=lambda s: node_position.get(s, 0))
|
|
115
|
+
|
|
116
|
+
# Route each edge
|
|
117
|
+
for source, target in edges:
|
|
118
|
+
# Skip dummy nodes - they're handled in the path
|
|
119
|
+
if source.startswith("__dummy_") or target.startswith("__dummy_"):
|
|
120
|
+
# For dummy nodes, we just pass through
|
|
121
|
+
route = self._route_through_dummy(
|
|
122
|
+
source, target, node_layer, node_position, edges
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
route = self._route_edge(
|
|
126
|
+
source,
|
|
127
|
+
target,
|
|
128
|
+
node_layer,
|
|
129
|
+
node_position,
|
|
130
|
+
edges_by_source.get(source, []),
|
|
131
|
+
edges_by_target.get(target, []),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if route:
|
|
135
|
+
routes.append(route)
|
|
136
|
+
|
|
137
|
+
return routes
|
|
138
|
+
|
|
139
|
+
def _route_edge(
|
|
140
|
+
self,
|
|
141
|
+
source: str,
|
|
142
|
+
target: str,
|
|
143
|
+
node_layer: Dict[str, int],
|
|
144
|
+
node_position: Dict[str, int],
|
|
145
|
+
source_targets: List[str],
|
|
146
|
+
target_sources: List[str],
|
|
147
|
+
) -> Optional[EdgeRoute]:
|
|
148
|
+
"""Route a single edge between two boxes."""
|
|
149
|
+
if source not in self.boxes or target not in self.boxes:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
src_box = self.boxes[source]
|
|
153
|
+
tgt_box = self.boxes[target]
|
|
154
|
+
|
|
155
|
+
src_layer = node_layer.get(source, 0)
|
|
156
|
+
tgt_layer = node_layer.get(target, 0)
|
|
157
|
+
|
|
158
|
+
# Determine port sides based on relative layer positions
|
|
159
|
+
if tgt_layer > src_layer:
|
|
160
|
+
# Target is below source - standard downward flow
|
|
161
|
+
src_side = PortSide.BOTTOM
|
|
162
|
+
tgt_side = PortSide.TOP
|
|
163
|
+
elif tgt_layer < src_layer:
|
|
164
|
+
# Target is above source - upward flow (back edge)
|
|
165
|
+
src_side = PortSide.TOP
|
|
166
|
+
tgt_side = PortSide.BOTTOM
|
|
167
|
+
else:
|
|
168
|
+
# Same layer - horizontal flow
|
|
169
|
+
if node_position.get(target, 0) > node_position.get(source, 0):
|
|
170
|
+
src_side = PortSide.RIGHT
|
|
171
|
+
tgt_side = PortSide.LEFT
|
|
172
|
+
else:
|
|
173
|
+
src_side = PortSide.LEFT
|
|
174
|
+
tgt_side = PortSide.RIGHT
|
|
175
|
+
|
|
176
|
+
# Allocate ports
|
|
177
|
+
src_port = self._allocate_port(
|
|
178
|
+
source,
|
|
179
|
+
src_side,
|
|
180
|
+
src_box,
|
|
181
|
+
source_targets.index(target) if target in source_targets else 0,
|
|
182
|
+
len(source_targets),
|
|
183
|
+
)
|
|
184
|
+
tgt_port = self._allocate_port(
|
|
185
|
+
target,
|
|
186
|
+
tgt_side,
|
|
187
|
+
tgt_box,
|
|
188
|
+
target_sources.index(source) if source in target_sources else 0,
|
|
189
|
+
len(target_sources),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Calculate waypoints for orthogonal routing
|
|
193
|
+
waypoints = self._calculate_waypoints(src_port, tgt_port, src_box, tgt_box)
|
|
194
|
+
|
|
195
|
+
return EdgeRoute(
|
|
196
|
+
source=source,
|
|
197
|
+
target=target,
|
|
198
|
+
source_port=src_port,
|
|
199
|
+
target_port=tgt_port,
|
|
200
|
+
waypoints=waypoints,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _route_through_dummy(
|
|
204
|
+
self,
|
|
205
|
+
source: str,
|
|
206
|
+
target: str,
|
|
207
|
+
node_layer: Dict[str, int],
|
|
208
|
+
node_position: Dict[str, int],
|
|
209
|
+
all_edges: List[Tuple[str, str]],
|
|
210
|
+
) -> Optional[EdgeRoute]:
|
|
211
|
+
"""Handle routing through dummy nodes."""
|
|
212
|
+
# For dummy nodes, just create straight vertical segments
|
|
213
|
+
if source not in self.boxes or target not in self.boxes:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
src_box = self.boxes[source]
|
|
217
|
+
tgt_box = self.boxes[target]
|
|
218
|
+
|
|
219
|
+
src_layer = node_layer.get(source, 0)
|
|
220
|
+
tgt_layer = node_layer.get(target, 0)
|
|
221
|
+
|
|
222
|
+
if tgt_layer > src_layer:
|
|
223
|
+
src_side = PortSide.BOTTOM
|
|
224
|
+
tgt_side = PortSide.TOP
|
|
225
|
+
else:
|
|
226
|
+
src_side = PortSide.TOP
|
|
227
|
+
tgt_side = PortSide.BOTTOM
|
|
228
|
+
|
|
229
|
+
src_port = Port(
|
|
230
|
+
node=source,
|
|
231
|
+
side=src_side,
|
|
232
|
+
offset=0,
|
|
233
|
+
x=src_box.x + src_box.width // 2,
|
|
234
|
+
y=src_box.y + src_box.height if src_side == PortSide.BOTTOM else src_box.y,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
tgt_port = Port(
|
|
238
|
+
node=target,
|
|
239
|
+
side=tgt_side,
|
|
240
|
+
offset=0,
|
|
241
|
+
x=tgt_box.x + tgt_box.width // 2,
|
|
242
|
+
y=tgt_box.y if tgt_side == PortSide.TOP else tgt_box.y + tgt_box.height,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return EdgeRoute(
|
|
246
|
+
source=source,
|
|
247
|
+
target=target,
|
|
248
|
+
source_port=src_port,
|
|
249
|
+
target_port=tgt_port,
|
|
250
|
+
waypoints=[(src_port.x, src_port.y), (tgt_port.x, tgt_port.y)],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _allocate_port(
|
|
254
|
+
self, node: str, side: PortSide, box: BoxInfo, index: int, total: int
|
|
255
|
+
) -> Port:
|
|
256
|
+
"""
|
|
257
|
+
Allocate a port on a box side, distributing ports evenly.
|
|
258
|
+
For bottom ports, y is at the bottom border (box.y + box.height - 1).
|
|
259
|
+
For top ports, y is at the top border (box.y).
|
|
260
|
+
"""
|
|
261
|
+
if side in (PortSide.TOP, PortSide.BOTTOM):
|
|
262
|
+
# Distribute horizontally across the box width
|
|
263
|
+
available_width = box.width - 2 # Exclude corners
|
|
264
|
+
if total == 1:
|
|
265
|
+
offset = available_width // 2
|
|
266
|
+
else:
|
|
267
|
+
spacing = available_width // (total + 1)
|
|
268
|
+
offset = spacing * (index + 1)
|
|
269
|
+
|
|
270
|
+
x = box.x + 1 + offset
|
|
271
|
+
# Bottom port at the bottom border, top port at top border
|
|
272
|
+
if side == PortSide.BOTTOM:
|
|
273
|
+
y = box.y + box.height - 1 # At bottom border
|
|
274
|
+
else:
|
|
275
|
+
y = box.y # At top border
|
|
276
|
+
|
|
277
|
+
else: # LEFT or RIGHT
|
|
278
|
+
# Distribute vertically
|
|
279
|
+
available_height = box.height - 2 # Exclude corners
|
|
280
|
+
if total == 1:
|
|
281
|
+
offset = available_height // 2
|
|
282
|
+
else:
|
|
283
|
+
spacing = available_height // (total + 1)
|
|
284
|
+
offset = spacing * (index + 1)
|
|
285
|
+
|
|
286
|
+
x = box.x if side == PortSide.LEFT else box.x + box.width - 1
|
|
287
|
+
y = box.y + 1 + offset
|
|
288
|
+
|
|
289
|
+
# Track used port
|
|
290
|
+
self.used_ports[node][side].add(offset)
|
|
291
|
+
|
|
292
|
+
return Port(node=node, side=side, offset=offset, x=x, y=y)
|
|
293
|
+
|
|
294
|
+
def _calculate_waypoints(
|
|
295
|
+
self, src_port: Port, tgt_port: Port, src_box: BoxInfo, tgt_box: BoxInfo
|
|
296
|
+
) -> List[Tuple[int, int]]:
|
|
297
|
+
"""
|
|
298
|
+
Calculate waypoints for orthogonal edge routing.
|
|
299
|
+
Returns list of (x, y) coordinates for the path.
|
|
300
|
+
"""
|
|
301
|
+
waypoints = [(src_port.x, src_port.y)]
|
|
302
|
+
|
|
303
|
+
src_x, src_y = src_port.x, src_port.y
|
|
304
|
+
tgt_x, tgt_y = tgt_port.x, tgt_port.y
|
|
305
|
+
|
|
306
|
+
if src_port.side == PortSide.BOTTOM and tgt_port.side == PortSide.TOP:
|
|
307
|
+
# Standard downward flow
|
|
308
|
+
if src_x == tgt_x:
|
|
309
|
+
# Direct vertical line
|
|
310
|
+
waypoints.append((tgt_x, tgt_y))
|
|
311
|
+
else:
|
|
312
|
+
# Need horizontal segment in between
|
|
313
|
+
mid_y = src_y + (tgt_y - src_y) // 2
|
|
314
|
+
waypoints.append((src_x, mid_y))
|
|
315
|
+
waypoints.append((tgt_x, mid_y))
|
|
316
|
+
waypoints.append((tgt_x, tgt_y))
|
|
317
|
+
|
|
318
|
+
elif src_port.side == PortSide.TOP and tgt_port.side == PortSide.BOTTOM:
|
|
319
|
+
# Upward flow (back edge)
|
|
320
|
+
# Route around the side
|
|
321
|
+
if src_x == tgt_x:
|
|
322
|
+
waypoints.append((tgt_x, tgt_y))
|
|
323
|
+
else:
|
|
324
|
+
mid_y = src_y - 2
|
|
325
|
+
waypoints.append((src_x, mid_y))
|
|
326
|
+
waypoints.append((tgt_x, mid_y))
|
|
327
|
+
waypoints.append((tgt_x, tgt_y))
|
|
328
|
+
|
|
329
|
+
elif src_port.side in (PortSide.LEFT, PortSide.RIGHT):
|
|
330
|
+
# Horizontal flow
|
|
331
|
+
if src_y == tgt_y:
|
|
332
|
+
waypoints.append((tgt_x, tgt_y))
|
|
333
|
+
else:
|
|
334
|
+
mid_x = src_x + (tgt_x - src_x) // 2
|
|
335
|
+
waypoints.append((mid_x, src_y))
|
|
336
|
+
waypoints.append((mid_x, tgt_y))
|
|
337
|
+
waypoints.append((tgt_x, tgt_y))
|
|
338
|
+
|
|
339
|
+
else:
|
|
340
|
+
# Default: direct path
|
|
341
|
+
waypoints.append((tgt_x, tgt_y))
|
|
342
|
+
|
|
343
|
+
return waypoints
|