frameplot 0.1.0__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.
- frameplot/__init__.py +7 -0
- frameplot/api.py +61 -0
- frameplot/layout/__init__.py +268 -0
- frameplot/layout/order.py +169 -0
- frameplot/layout/place.py +193 -0
- frameplot/layout/rank.py +54 -0
- frameplot/layout/route.py +1031 -0
- frameplot/layout/scc.py +69 -0
- frameplot/layout/text.py +74 -0
- frameplot/layout/types.py +165 -0
- frameplot/layout/validate.py +108 -0
- frameplot/model.py +129 -0
- frameplot/render/__init__.py +4 -0
- frameplot/render/png.py +20 -0
- frameplot/render/svg.py +306 -0
- frameplot/theme.py +59 -0
- frameplot-0.1.0.dist-info/METADATA +132 -0
- frameplot-0.1.0.dist-info/RECORD +21 -0
- frameplot-0.1.0.dist-info/WHEEL +5 -0
- frameplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- frameplot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from frameplot.layout.types import Bounds, GroupOverlay, LayoutNode, Point, RoutedEdge, union_bounds
|
|
7
|
+
|
|
8
|
+
EPSILON = 0.01
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class ComponentGeometry:
|
|
13
|
+
rank_left: dict[int, float]
|
|
14
|
+
rank_right: dict[int, float]
|
|
15
|
+
row_top: dict[int, float]
|
|
16
|
+
row_bottom: dict[int, float]
|
|
17
|
+
gap_after_rank: dict[int, tuple[float, float]]
|
|
18
|
+
gap_before_rank: dict[int, tuple[float, float]]
|
|
19
|
+
gap_after_row: dict[int, tuple[float, float]]
|
|
20
|
+
gap_before_row: dict[int, tuple[float, float]]
|
|
21
|
+
outer_top: float
|
|
22
|
+
outer_bottom: float
|
|
23
|
+
outer_right: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class CandidatePath:
|
|
28
|
+
points: tuple[Point, ...]
|
|
29
|
+
reserved_segments: tuple[int, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True, frozen=True)
|
|
33
|
+
class StoredSegment:
|
|
34
|
+
start: float
|
|
35
|
+
end: float
|
|
36
|
+
edge_id: str
|
|
37
|
+
overlap_locked: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class Occupancy:
|
|
42
|
+
horizontal: dict[float, list[StoredSegment]] = field(
|
|
43
|
+
default_factory=lambda: defaultdict(list)
|
|
44
|
+
)
|
|
45
|
+
vertical: dict[float, list[StoredSegment]] = field(
|
|
46
|
+
default_factory=lambda: defaultdict(list)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def route_edges(
|
|
51
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
52
|
+
nodes: dict[str, LayoutNode],
|
|
53
|
+
) -> tuple[RoutedEdge, ...]:
|
|
54
|
+
component_geometry = _build_component_geometry(nodes, validated.theme)
|
|
55
|
+
forward_outgoing: dict[str, list["Edge"]] = defaultdict(list)
|
|
56
|
+
forward_incoming: dict[str, list["Edge"]] = defaultdict(list)
|
|
57
|
+
|
|
58
|
+
for edge in validated.edges:
|
|
59
|
+
source_node = nodes[edge.source]
|
|
60
|
+
target_node = nodes[edge.target]
|
|
61
|
+
if edge.source != edge.target and source_node.rank < target_node.rank:
|
|
62
|
+
forward_outgoing[edge.source].append(edge)
|
|
63
|
+
forward_incoming[target_node.node.id].append(edge)
|
|
64
|
+
|
|
65
|
+
pair_offsets = _assign_pair_offsets(validated, nodes)
|
|
66
|
+
back_slots = _assign_back_slots(validated, nodes)
|
|
67
|
+
self_loop_slots = _assign_self_loop_slots(validated)
|
|
68
|
+
occupancies: dict[int, Occupancy] = defaultdict(Occupancy)
|
|
69
|
+
routed_by_id: dict[str, RoutedEdge] = {}
|
|
70
|
+
|
|
71
|
+
ordered_edges = sorted(
|
|
72
|
+
validated.edges,
|
|
73
|
+
key=lambda edge: _edge_route_order(edge, nodes, validated.edge_index[edge.id]),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for edge in ordered_edges:
|
|
77
|
+
source_node = nodes[edge.source]
|
|
78
|
+
target_node = nodes[edge.target]
|
|
79
|
+
geometry = component_geometry[source_node.component_id]
|
|
80
|
+
|
|
81
|
+
if edge.source == edge.target:
|
|
82
|
+
points = _select_self_loop_route(
|
|
83
|
+
source_node=source_node,
|
|
84
|
+
geometry=geometry,
|
|
85
|
+
occupancy=occupancies[source_node.component_id],
|
|
86
|
+
base_slot=self_loop_slots[edge.id],
|
|
87
|
+
pair_offset=pair_offsets.get(edge.id, 0.0),
|
|
88
|
+
theme=validated.theme,
|
|
89
|
+
)
|
|
90
|
+
_reserve_points(occupancies[source_node.component_id], points, edge.id)
|
|
91
|
+
elif source_node.rank < target_node.rank:
|
|
92
|
+
candidate = _select_forward_route(
|
|
93
|
+
edge=edge,
|
|
94
|
+
source_node=source_node,
|
|
95
|
+
target_node=target_node,
|
|
96
|
+
geometry=geometry,
|
|
97
|
+
nodes=nodes,
|
|
98
|
+
occupancy=occupancies[source_node.component_id],
|
|
99
|
+
outgoing_count=len(forward_outgoing[edge.source]),
|
|
100
|
+
incoming_count=len(forward_incoming[edge.target]),
|
|
101
|
+
pair_offset=pair_offsets.get(edge.id, 0.0),
|
|
102
|
+
theme=validated.theme,
|
|
103
|
+
)
|
|
104
|
+
points = candidate.points
|
|
105
|
+
_reserve_candidate(occupancies[source_node.component_id], candidate, edge.id)
|
|
106
|
+
else:
|
|
107
|
+
points = _select_back_edge_route(
|
|
108
|
+
source_node=source_node,
|
|
109
|
+
target_node=target_node,
|
|
110
|
+
geometry=geometry,
|
|
111
|
+
occupancy=occupancies[source_node.component_id],
|
|
112
|
+
base_slot=back_slots[edge.id],
|
|
113
|
+
pair_offset=pair_offsets.get(edge.id, 0.0),
|
|
114
|
+
theme=validated.theme,
|
|
115
|
+
)
|
|
116
|
+
_reserve_points(occupancies[source_node.component_id], points, edge.id)
|
|
117
|
+
|
|
118
|
+
routed_by_id[edge.id] = RoutedEdge(
|
|
119
|
+
edge=edge,
|
|
120
|
+
points=points,
|
|
121
|
+
bounds=_bounds_for_points(points, validated.theme.stroke_width, validated.theme.arrow_size),
|
|
122
|
+
stroke=edge.color or validated.theme.edge_color,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return tuple(routed_by_id[edge.id] for edge in validated.edges)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def compute_group_overlays(
|
|
129
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
130
|
+
nodes: dict[str, LayoutNode],
|
|
131
|
+
routed_edges: tuple[RoutedEdge, ...],
|
|
132
|
+
) -> tuple[GroupOverlay, ...]:
|
|
133
|
+
edge_lookup = {route.edge.id: route for route in routed_edges}
|
|
134
|
+
overlays: list[GroupOverlay] = []
|
|
135
|
+
|
|
136
|
+
for group in validated.groups:
|
|
137
|
+
group_bounds = [nodes[node_id].bounds for node_id in group.node_ids]
|
|
138
|
+
group_bounds.extend(edge_lookup[edge_id].bounds for edge_id in group.edge_ids)
|
|
139
|
+
bounds = union_bounds(group_bounds).expand(validated.theme.group_padding)
|
|
140
|
+
label_padding = validated.theme.subtitle_font_size + validated.theme.node_padding_y
|
|
141
|
+
bounds = Bounds(
|
|
142
|
+
x=bounds.x,
|
|
143
|
+
y=bounds.y - label_padding * 0.5,
|
|
144
|
+
width=bounds.width,
|
|
145
|
+
height=bounds.height + label_padding * 0.5,
|
|
146
|
+
)
|
|
147
|
+
overlays.append(
|
|
148
|
+
GroupOverlay(
|
|
149
|
+
group=group,
|
|
150
|
+
bounds=bounds,
|
|
151
|
+
stroke=group.stroke or validated.theme.group_stroke,
|
|
152
|
+
fill=group.fill or validated.theme.group_fill,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return tuple(overlays)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build_component_geometry(
|
|
160
|
+
nodes: dict[str, LayoutNode],
|
|
161
|
+
theme: "Theme",
|
|
162
|
+
) -> dict[int, ComponentGeometry]:
|
|
163
|
+
by_component: dict[int, list[LayoutNode]] = defaultdict(list)
|
|
164
|
+
for node in nodes.values():
|
|
165
|
+
by_component[node.component_id].append(node)
|
|
166
|
+
|
|
167
|
+
geometry_by_component: dict[int, ComponentGeometry] = {}
|
|
168
|
+
for component_id, component_nodes in by_component.items():
|
|
169
|
+
rank_nodes: dict[int, list[LayoutNode]] = defaultdict(list)
|
|
170
|
+
row_nodes: dict[int, list[LayoutNode]] = defaultdict(list)
|
|
171
|
+
|
|
172
|
+
for node in component_nodes:
|
|
173
|
+
rank_nodes[node.rank].append(node)
|
|
174
|
+
row_nodes[node.order].append(node)
|
|
175
|
+
|
|
176
|
+
sorted_ranks = sorted(rank_nodes)
|
|
177
|
+
sorted_rows = sorted(row_nodes)
|
|
178
|
+
|
|
179
|
+
rank_left = {rank: min(node.x for node in rank_nodes[rank]) for rank in sorted_ranks}
|
|
180
|
+
rank_right = {rank: max(node.right for node in rank_nodes[rank]) for rank in sorted_ranks}
|
|
181
|
+
row_top = {row: min(node.y for node in row_nodes[row]) for row in sorted_rows}
|
|
182
|
+
row_bottom = {row: max(node.bounds.bottom for node in row_nodes[row]) for row in sorted_rows}
|
|
183
|
+
|
|
184
|
+
gap_after_rank: dict[int, tuple[float, float]] = {}
|
|
185
|
+
gap_before_rank: dict[int, tuple[float, float]] = {}
|
|
186
|
+
for left_rank, right_rank in zip(sorted_ranks, sorted_ranks[1:]):
|
|
187
|
+
gap = (rank_right[left_rank], rank_left[right_rank])
|
|
188
|
+
gap_after_rank[left_rank] = gap
|
|
189
|
+
gap_before_rank[right_rank] = gap
|
|
190
|
+
|
|
191
|
+
gap_after_row: dict[int, tuple[float, float]] = {}
|
|
192
|
+
gap_before_row: dict[int, tuple[float, float]] = {}
|
|
193
|
+
for upper_row, lower_row in zip(sorted_rows, sorted_rows[1:]):
|
|
194
|
+
gap = (row_bottom[upper_row], row_top[lower_row])
|
|
195
|
+
gap_after_row[upper_row] = gap
|
|
196
|
+
gap_before_row[lower_row] = gap
|
|
197
|
+
|
|
198
|
+
geometry_by_component[component_id] = ComponentGeometry(
|
|
199
|
+
rank_left=rank_left,
|
|
200
|
+
rank_right=rank_right,
|
|
201
|
+
row_top=row_top,
|
|
202
|
+
row_bottom=row_bottom,
|
|
203
|
+
gap_after_rank=gap_after_rank,
|
|
204
|
+
gap_before_rank=gap_before_rank,
|
|
205
|
+
gap_after_row=gap_after_row,
|
|
206
|
+
gap_before_row=gap_before_row,
|
|
207
|
+
outer_top=round(min(row_top.values()) - theme.back_edge_gap, 2),
|
|
208
|
+
outer_bottom=round(max(row_bottom.values()) + theme.back_edge_gap, 2),
|
|
209
|
+
outer_right=round(max(rank_right.values()) + theme.back_edge_gap * 2, 2),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return geometry_by_component
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _assign_pair_offsets(
|
|
216
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
217
|
+
nodes: dict[str, LayoutNode],
|
|
218
|
+
) -> dict[str, float]:
|
|
219
|
+
groups: dict[tuple[str, str], list["Edge"]] = defaultdict(list)
|
|
220
|
+
offsets: dict[str, float] = {}
|
|
221
|
+
|
|
222
|
+
for edge in validated.edges:
|
|
223
|
+
groups[(edge.source, edge.target)].append(edge)
|
|
224
|
+
|
|
225
|
+
for edges in groups.values():
|
|
226
|
+
ordered = sorted(edges, key=lambda edge: edge.id)
|
|
227
|
+
max_offset = min(
|
|
228
|
+
validated.theme.route_track_gap * 0.4,
|
|
229
|
+
min(nodes[ordered[0].source].height, nodes[ordered[0].target].height) / 4,
|
|
230
|
+
)
|
|
231
|
+
centered = _centered_offsets(len(ordered), max_offset if max_offset > 0 else 0.0)
|
|
232
|
+
for edge, offset in zip(ordered, centered, strict=True):
|
|
233
|
+
offsets[edge.id] = offset
|
|
234
|
+
|
|
235
|
+
return offsets
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _assign_back_slots(
|
|
239
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
240
|
+
nodes: dict[str, LayoutNode],
|
|
241
|
+
) -> dict[str, int]:
|
|
242
|
+
by_component: dict[int, list["Edge"]] = defaultdict(list)
|
|
243
|
+
for edge in validated.edges:
|
|
244
|
+
source_node = nodes[edge.source]
|
|
245
|
+
target_node = nodes[edge.target]
|
|
246
|
+
if edge.source != edge.target and source_node.rank >= target_node.rank:
|
|
247
|
+
by_component[source_node.component_id].append(edge)
|
|
248
|
+
|
|
249
|
+
slots: dict[str, int] = {}
|
|
250
|
+
for edges in by_component.values():
|
|
251
|
+
ordered = sorted(
|
|
252
|
+
edges,
|
|
253
|
+
key=lambda edge: (
|
|
254
|
+
nodes[edge.source].rank,
|
|
255
|
+
nodes[edge.target].rank,
|
|
256
|
+
nodes[edge.source].order,
|
|
257
|
+
nodes[edge.target].order,
|
|
258
|
+
edge.id,
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
for slot, edge in enumerate(ordered):
|
|
262
|
+
slots[edge.id] = slot
|
|
263
|
+
return slots
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _assign_self_loop_slots(validated: "ValidatedPipeline | ValidatedDetailPanel") -> dict[str, int]:
|
|
267
|
+
by_node: dict[str, list["Edge"]] = defaultdict(list)
|
|
268
|
+
for edge in validated.edges:
|
|
269
|
+
if edge.source == edge.target:
|
|
270
|
+
by_node[edge.source].append(edge)
|
|
271
|
+
|
|
272
|
+
slots: dict[str, int] = {}
|
|
273
|
+
for edges in by_node.values():
|
|
274
|
+
for slot, edge in enumerate(sorted(edges, key=lambda edge: edge.id)):
|
|
275
|
+
slots[edge.id] = slot
|
|
276
|
+
return slots
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _edge_route_order(edge: "Edge", nodes: dict[str, LayoutNode], edge_index: int) -> tuple[int, int, int, int, int]:
|
|
280
|
+
source_node = nodes[edge.source]
|
|
281
|
+
target_node = nodes[edge.target]
|
|
282
|
+
if edge.source == edge.target:
|
|
283
|
+
kind = 2
|
|
284
|
+
elif source_node.rank < target_node.rank:
|
|
285
|
+
kind = 0
|
|
286
|
+
else:
|
|
287
|
+
kind = 1
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
kind,
|
|
291
|
+
abs(target_node.rank - source_node.rank),
|
|
292
|
+
abs(target_node.order - source_node.order),
|
|
293
|
+
source_node.rank,
|
|
294
|
+
edge_index,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _select_forward_route(
|
|
299
|
+
*,
|
|
300
|
+
edge: "Edge",
|
|
301
|
+
source_node: LayoutNode,
|
|
302
|
+
target_node: LayoutNode,
|
|
303
|
+
geometry: ComponentGeometry,
|
|
304
|
+
nodes: dict[str, LayoutNode],
|
|
305
|
+
occupancy: Occupancy,
|
|
306
|
+
outgoing_count: int,
|
|
307
|
+
incoming_count: int,
|
|
308
|
+
pair_offset: float,
|
|
309
|
+
theme: "Theme",
|
|
310
|
+
) -> CandidatePath:
|
|
311
|
+
candidates = _build_forward_candidates(
|
|
312
|
+
source_node=source_node,
|
|
313
|
+
target_node=target_node,
|
|
314
|
+
geometry=geometry,
|
|
315
|
+
pair_offset=pair_offset,
|
|
316
|
+
theme=theme,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
best: CandidatePath | None = None
|
|
320
|
+
best_score: float | None = None
|
|
321
|
+
fallback: CandidatePath | None = None
|
|
322
|
+
fallback_score: float | None = None
|
|
323
|
+
|
|
324
|
+
for kind, candidate in candidates:
|
|
325
|
+
score = _forward_score(
|
|
326
|
+
kind=kind,
|
|
327
|
+
candidate=candidate,
|
|
328
|
+
source_node=source_node,
|
|
329
|
+
target_node=target_node,
|
|
330
|
+
nodes=nodes,
|
|
331
|
+
outgoing_count=outgoing_count,
|
|
332
|
+
incoming_count=incoming_count,
|
|
333
|
+
theme=theme,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if fallback_score is None or score < fallback_score:
|
|
337
|
+
fallback = candidate
|
|
338
|
+
fallback_score = score
|
|
339
|
+
|
|
340
|
+
if _candidate_conflicts(candidate, occupancy):
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if best_score is None or score < best_score:
|
|
344
|
+
best = candidate
|
|
345
|
+
best_score = score
|
|
346
|
+
|
|
347
|
+
if best is not None:
|
|
348
|
+
return best
|
|
349
|
+
assert fallback is not None
|
|
350
|
+
return fallback
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _route_forward(
|
|
354
|
+
*,
|
|
355
|
+
edge: "Edge",
|
|
356
|
+
source_node: LayoutNode,
|
|
357
|
+
target_node: LayoutNode,
|
|
358
|
+
geometry: ComponentGeometry,
|
|
359
|
+
nodes: dict[str, LayoutNode],
|
|
360
|
+
outgoing_count: int,
|
|
361
|
+
incoming_count: int,
|
|
362
|
+
pair_offset: float,
|
|
363
|
+
theme: "Theme",
|
|
364
|
+
) -> tuple[Point, ...]:
|
|
365
|
+
return _select_forward_route(
|
|
366
|
+
edge=edge,
|
|
367
|
+
source_node=source_node,
|
|
368
|
+
target_node=target_node,
|
|
369
|
+
geometry=geometry,
|
|
370
|
+
nodes=nodes,
|
|
371
|
+
occupancy=Occupancy(),
|
|
372
|
+
outgoing_count=outgoing_count,
|
|
373
|
+
incoming_count=incoming_count,
|
|
374
|
+
pair_offset=pair_offset,
|
|
375
|
+
theme=theme,
|
|
376
|
+
).points
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _build_forward_candidates(
|
|
380
|
+
*,
|
|
381
|
+
source_node: LayoutNode,
|
|
382
|
+
target_node: LayoutNode,
|
|
383
|
+
geometry: ComponentGeometry,
|
|
384
|
+
pair_offset: float,
|
|
385
|
+
theme: "Theme",
|
|
386
|
+
) -> list[tuple[str, CandidatePath]]:
|
|
387
|
+
candidates: list[tuple[str, CandidatePath]] = []
|
|
388
|
+
|
|
389
|
+
if source_node.order == target_node.order:
|
|
390
|
+
candidates.append(("direct", _direct_same_row_candidate(source_node, target_node, geometry, pair_offset, theme)))
|
|
391
|
+
|
|
392
|
+
if source_node.rank in geometry.gap_after_rank:
|
|
393
|
+
for lane_x in _ordered_gap_positions(geometry.gap_after_rank[source_node.rank], theme, "start"):
|
|
394
|
+
candidates.append(
|
|
395
|
+
(
|
|
396
|
+
"column_source",
|
|
397
|
+
_column_source_candidate(source_node, target_node, lane_x, pair_offset),
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if target_node.rank in geometry.gap_before_rank:
|
|
402
|
+
for lane_x in _ordered_gap_positions(geometry.gap_before_rank[target_node.rank], theme, "end"):
|
|
403
|
+
candidates.append(
|
|
404
|
+
(
|
|
405
|
+
"column_target",
|
|
406
|
+
_column_target_candidate(source_node, target_node, lane_x, pair_offset),
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
for lane_y in _source_row_lane_positions(source_node, target_node, geometry, theme):
|
|
411
|
+
candidates.append(("row_source", _row_source_candidate(source_node, target_node, lane_y, pair_offset)))
|
|
412
|
+
|
|
413
|
+
for lane_y in _target_row_lane_positions(source_node, target_node, geometry, theme):
|
|
414
|
+
candidates.append(("row_target", _row_target_candidate(source_node, target_node, lane_y, pair_offset)))
|
|
415
|
+
|
|
416
|
+
for lane_y in _outer_row_lane_positions(geometry, theme):
|
|
417
|
+
candidates.append(("outer_row", _outer_row_candidate(source_node, target_node, lane_y, pair_offset)))
|
|
418
|
+
|
|
419
|
+
for lane_x in _outer_column_lane_positions(geometry, theme):
|
|
420
|
+
candidates.append(("outer_column", _outer_column_candidate(source_node, target_node, lane_x, pair_offset)))
|
|
421
|
+
|
|
422
|
+
return candidates
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _direct_same_row_candidate(
|
|
426
|
+
source_node: LayoutNode,
|
|
427
|
+
target_node: LayoutNode,
|
|
428
|
+
geometry: ComponentGeometry,
|
|
429
|
+
pair_offset: float,
|
|
430
|
+
theme: "Theme",
|
|
431
|
+
) -> CandidatePath:
|
|
432
|
+
y = round(source_node.center_y + pair_offset, 2)
|
|
433
|
+
source = Point(source_node.right, y)
|
|
434
|
+
target = Point(target_node.x, y)
|
|
435
|
+
split_x = _stub_x_after_rank(source_node.rank, geometry, theme)
|
|
436
|
+
merge_x = _stub_x_before_rank(target_node.rank, geometry, theme)
|
|
437
|
+
|
|
438
|
+
if split_x >= merge_x - EPSILON:
|
|
439
|
+
return _build_candidate(
|
|
440
|
+
source,
|
|
441
|
+
[(target, "reserve")],
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return _build_candidate(
|
|
445
|
+
source,
|
|
446
|
+
[
|
|
447
|
+
(Point(split_x, y), "stub"),
|
|
448
|
+
(Point(merge_x, y), "reserve"),
|
|
449
|
+
(target, "stub"),
|
|
450
|
+
],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _column_source_candidate(
|
|
455
|
+
source_node: LayoutNode,
|
|
456
|
+
target_node: LayoutNode,
|
|
457
|
+
lane_x: float,
|
|
458
|
+
pair_offset: float,
|
|
459
|
+
) -> CandidatePath:
|
|
460
|
+
source = _right_port(source_node, pair_offset)
|
|
461
|
+
target = _left_port(target_node, pair_offset)
|
|
462
|
+
return _build_candidate(
|
|
463
|
+
source,
|
|
464
|
+
[
|
|
465
|
+
(Point(lane_x, source.y), "stub"),
|
|
466
|
+
(Point(lane_x, target.y), "reserve"),
|
|
467
|
+
(target, "reserve"),
|
|
468
|
+
],
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _column_target_candidate(
|
|
473
|
+
source_node: LayoutNode,
|
|
474
|
+
target_node: LayoutNode,
|
|
475
|
+
lane_x: float,
|
|
476
|
+
pair_offset: float,
|
|
477
|
+
) -> CandidatePath:
|
|
478
|
+
source = _right_port(source_node, pair_offset)
|
|
479
|
+
target = _left_port(target_node, pair_offset)
|
|
480
|
+
return _build_candidate(
|
|
481
|
+
source,
|
|
482
|
+
[
|
|
483
|
+
(Point(lane_x, source.y), "reserve"),
|
|
484
|
+
(Point(lane_x, target.y), "reserve"),
|
|
485
|
+
(target, "stub"),
|
|
486
|
+
],
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _row_source_candidate(
|
|
491
|
+
source_node: LayoutNode,
|
|
492
|
+
target_node: LayoutNode,
|
|
493
|
+
lane_y: float,
|
|
494
|
+
pair_offset: float,
|
|
495
|
+
) -> CandidatePath:
|
|
496
|
+
source = _vertical_port(source_node, pair_offset, lane_y)
|
|
497
|
+
target = _vertical_port(target_node, pair_offset, lane_y)
|
|
498
|
+
return _build_candidate(
|
|
499
|
+
source,
|
|
500
|
+
[
|
|
501
|
+
(Point(source.x, lane_y), "stub"),
|
|
502
|
+
(Point(target.x, lane_y), "reserve"),
|
|
503
|
+
(target, "reserve"),
|
|
504
|
+
],
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _row_target_candidate(
|
|
509
|
+
source_node: LayoutNode,
|
|
510
|
+
target_node: LayoutNode,
|
|
511
|
+
lane_y: float,
|
|
512
|
+
pair_offset: float,
|
|
513
|
+
) -> CandidatePath:
|
|
514
|
+
source = _vertical_port(source_node, pair_offset, lane_y)
|
|
515
|
+
target = _vertical_port(target_node, pair_offset, lane_y)
|
|
516
|
+
return _build_candidate(
|
|
517
|
+
source,
|
|
518
|
+
[
|
|
519
|
+
(Point(source.x, lane_y), "reserve"),
|
|
520
|
+
(Point(target.x, lane_y), "reserve"),
|
|
521
|
+
(target, "stub"),
|
|
522
|
+
],
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _outer_row_candidate(
|
|
527
|
+
source_node: LayoutNode,
|
|
528
|
+
target_node: LayoutNode,
|
|
529
|
+
lane_y: float,
|
|
530
|
+
pair_offset: float,
|
|
531
|
+
) -> CandidatePath:
|
|
532
|
+
source = _vertical_port(source_node, pair_offset, lane_y)
|
|
533
|
+
target = _vertical_port(target_node, pair_offset, lane_y)
|
|
534
|
+
return _build_candidate(
|
|
535
|
+
source,
|
|
536
|
+
[
|
|
537
|
+
(Point(source.x, lane_y), "reserve"),
|
|
538
|
+
(Point(target.x, lane_y), "reserve"),
|
|
539
|
+
(target, "reserve"),
|
|
540
|
+
],
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _outer_column_candidate(
|
|
545
|
+
source_node: LayoutNode,
|
|
546
|
+
target_node: LayoutNode,
|
|
547
|
+
lane_x: float,
|
|
548
|
+
pair_offset: float,
|
|
549
|
+
) -> CandidatePath:
|
|
550
|
+
source = _right_port(source_node, pair_offset)
|
|
551
|
+
target = _left_port(target_node, pair_offset)
|
|
552
|
+
return _build_candidate(
|
|
553
|
+
source,
|
|
554
|
+
[
|
|
555
|
+
(Point(lane_x, source.y), "reserve"),
|
|
556
|
+
(Point(lane_x, target.y), "reserve"),
|
|
557
|
+
(target, "reserve"),
|
|
558
|
+
],
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _build_candidate(
|
|
563
|
+
start: Point,
|
|
564
|
+
segments: list[tuple[Point, str]],
|
|
565
|
+
) -> CandidatePath:
|
|
566
|
+
points = [start]
|
|
567
|
+
kinds: list[str] = []
|
|
568
|
+
|
|
569
|
+
for point, kind in segments:
|
|
570
|
+
if point == points[-1]:
|
|
571
|
+
continue
|
|
572
|
+
points.append(point)
|
|
573
|
+
kinds.append(kind)
|
|
574
|
+
|
|
575
|
+
reserved = tuple(index for index, kind in enumerate(kinds) if kind == "reserve")
|
|
576
|
+
if not reserved and len(points) >= 2:
|
|
577
|
+
reserved = tuple(range(len(points) - 1))
|
|
578
|
+
|
|
579
|
+
return CandidatePath(points=tuple(points), reserved_segments=reserved)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _source_row_lane_positions(
|
|
583
|
+
source_node: LayoutNode,
|
|
584
|
+
target_node: LayoutNode,
|
|
585
|
+
geometry: ComponentGeometry,
|
|
586
|
+
theme: "Theme",
|
|
587
|
+
) -> tuple[float, ...]:
|
|
588
|
+
lanes: list[float] = []
|
|
589
|
+
|
|
590
|
+
if target_node.order >= source_node.order:
|
|
591
|
+
gap = geometry.gap_after_row.get(source_node.order)
|
|
592
|
+
if gap is not None:
|
|
593
|
+
lanes.extend(_ordered_gap_positions(gap, theme, "start"))
|
|
594
|
+
if target_node.order <= source_node.order:
|
|
595
|
+
gap = geometry.gap_before_row.get(source_node.order)
|
|
596
|
+
if gap is not None:
|
|
597
|
+
lanes.extend(_ordered_gap_positions(gap, theme, "end"))
|
|
598
|
+
|
|
599
|
+
return tuple(lanes)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _target_row_lane_positions(
|
|
603
|
+
source_node: LayoutNode,
|
|
604
|
+
target_node: LayoutNode,
|
|
605
|
+
geometry: ComponentGeometry,
|
|
606
|
+
theme: "Theme",
|
|
607
|
+
) -> tuple[float, ...]:
|
|
608
|
+
lanes: list[float] = []
|
|
609
|
+
|
|
610
|
+
if source_node.order <= target_node.order:
|
|
611
|
+
gap = geometry.gap_before_row.get(target_node.order)
|
|
612
|
+
if gap is not None:
|
|
613
|
+
lanes.extend(_ordered_gap_positions(gap, theme, "end"))
|
|
614
|
+
if source_node.order >= target_node.order:
|
|
615
|
+
gap = geometry.gap_after_row.get(target_node.order)
|
|
616
|
+
if gap is not None:
|
|
617
|
+
lanes.extend(_ordered_gap_positions(gap, theme, "start"))
|
|
618
|
+
|
|
619
|
+
return tuple(lanes)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _outer_row_lane_positions(
|
|
623
|
+
geometry: ComponentGeometry,
|
|
624
|
+
theme: "Theme",
|
|
625
|
+
) -> tuple[float, ...]:
|
|
626
|
+
return (
|
|
627
|
+
round(geometry.outer_top, 2),
|
|
628
|
+
round(geometry.outer_top - theme.route_track_gap, 2),
|
|
629
|
+
round(geometry.outer_bottom, 2),
|
|
630
|
+
round(geometry.outer_bottom + theme.route_track_gap, 2),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _outer_column_lane_positions(
|
|
635
|
+
geometry: ComponentGeometry,
|
|
636
|
+
theme: "Theme",
|
|
637
|
+
) -> tuple[float, ...]:
|
|
638
|
+
return (
|
|
639
|
+
round(geometry.outer_right, 2),
|
|
640
|
+
round(geometry.outer_right + theme.route_track_gap, 2),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _ordered_gap_positions(
|
|
645
|
+
gap: tuple[float, float],
|
|
646
|
+
theme: "Theme",
|
|
647
|
+
preferred_edge: str,
|
|
648
|
+
) -> tuple[float, ...]:
|
|
649
|
+
positions = _lane_positions(gap[0], gap[1], theme.route_track_gap)
|
|
650
|
+
boundary = gap[0] if preferred_edge == "start" else gap[1]
|
|
651
|
+
return tuple(
|
|
652
|
+
sorted(
|
|
653
|
+
positions,
|
|
654
|
+
key=lambda position: (
|
|
655
|
+
abs(position - boundary),
|
|
656
|
+
position if preferred_edge == "start" else -position,
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _lane_positions(start: float, end: float, step: float) -> tuple[float, ...]:
|
|
663
|
+
width = end - start
|
|
664
|
+
if width <= step:
|
|
665
|
+
return (round((start + end) / 2, 2),)
|
|
666
|
+
|
|
667
|
+
count = max(1, int(width // step))
|
|
668
|
+
total = step * (count - 1)
|
|
669
|
+
origin = (start + end) / 2 - total / 2
|
|
670
|
+
return tuple(round(origin + step * index, 2) for index in range(count))
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _stub_x_after_rank(rank: int, geometry: ComponentGeometry, theme: "Theme") -> float:
|
|
674
|
+
if rank not in geometry.gap_after_rank:
|
|
675
|
+
return round(geometry.rank_right[rank], 2)
|
|
676
|
+
start, end = geometry.gap_after_rank[rank]
|
|
677
|
+
return round(min(end, start + min(theme.route_track_gap, (end - start) / 2)), 2)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _stub_x_before_rank(rank: int, geometry: ComponentGeometry, theme: "Theme") -> float:
|
|
681
|
+
if rank not in geometry.gap_before_rank:
|
|
682
|
+
return round(geometry.rank_left[rank], 2)
|
|
683
|
+
start, end = geometry.gap_before_rank[rank]
|
|
684
|
+
return round(max(start, end - min(theme.route_track_gap, (end - start) / 2)), 2)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _right_port(node: LayoutNode, pair_offset: float) -> Point:
|
|
688
|
+
return Point(node.right, round(node.center_y + pair_offset, 2))
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _left_port(node: LayoutNode, pair_offset: float) -> Point:
|
|
692
|
+
return Point(node.x, round(node.center_y + pair_offset, 2))
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _vertical_port(node: LayoutNode, pair_offset: float, lane_y: float) -> Point:
|
|
696
|
+
x = round(node.center_x + pair_offset, 2)
|
|
697
|
+
if lane_y >= node.center_y:
|
|
698
|
+
return Point(x, node.bounds.bottom)
|
|
699
|
+
return Point(x, node.y)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _forward_score(
|
|
703
|
+
*,
|
|
704
|
+
kind: str,
|
|
705
|
+
candidate: CandidatePath,
|
|
706
|
+
source_node: LayoutNode,
|
|
707
|
+
target_node: LayoutNode,
|
|
708
|
+
nodes: dict[str, LayoutNode],
|
|
709
|
+
outgoing_count: int,
|
|
710
|
+
incoming_count: int,
|
|
711
|
+
theme: "Theme",
|
|
712
|
+
) -> float:
|
|
713
|
+
points = candidate.points
|
|
714
|
+
length = _path_length(points)
|
|
715
|
+
bends = _bend_count(points)
|
|
716
|
+
backwards = _backwards_distance(points)
|
|
717
|
+
collisions = _count_node_collisions(points, nodes, {source_node.node.id, target_node.node.id}, theme)
|
|
718
|
+
|
|
719
|
+
score = collisions * 100000 + backwards * 1000 + bends * 120 + length
|
|
720
|
+
|
|
721
|
+
if kind.startswith("row_"):
|
|
722
|
+
score += 50
|
|
723
|
+
if kind == "outer_row":
|
|
724
|
+
score += 280
|
|
725
|
+
if kind == "outer_column":
|
|
726
|
+
score += 340
|
|
727
|
+
if kind == "direct":
|
|
728
|
+
score -= 30
|
|
729
|
+
if kind.endswith("source") and outgoing_count > 1:
|
|
730
|
+
score -= 90
|
|
731
|
+
if kind.endswith("target") and incoming_count > 1:
|
|
732
|
+
score -= 90
|
|
733
|
+
if kind.endswith("source") and incoming_count > 1:
|
|
734
|
+
score += 25
|
|
735
|
+
if kind.endswith("target") and outgoing_count > 1:
|
|
736
|
+
score += 25
|
|
737
|
+
|
|
738
|
+
return score
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _candidate_conflicts(candidate: CandidatePath, occupancy: Occupancy) -> bool:
|
|
742
|
+
reserved_segments = set(candidate.reserved_segments)
|
|
743
|
+
for segment_index in range(len(candidate.points) - 1):
|
|
744
|
+
start = candidate.points[segment_index]
|
|
745
|
+
end = candidate.points[segment_index + 1]
|
|
746
|
+
if _segment_conflicts(
|
|
747
|
+
start,
|
|
748
|
+
end,
|
|
749
|
+
occupancy,
|
|
750
|
+
check_overlap=segment_index in reserved_segments,
|
|
751
|
+
):
|
|
752
|
+
return True
|
|
753
|
+
return False
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _segment_conflicts(
|
|
757
|
+
start: Point,
|
|
758
|
+
end: Point,
|
|
759
|
+
occupancy: Occupancy,
|
|
760
|
+
*,
|
|
761
|
+
check_overlap: bool,
|
|
762
|
+
) -> bool:
|
|
763
|
+
if start.x == end.x:
|
|
764
|
+
x = round(start.x, 2)
|
|
765
|
+
top, bottom = sorted((start.y, end.y))
|
|
766
|
+
for segment in occupancy.vertical.get(x, ()):
|
|
767
|
+
if (
|
|
768
|
+
check_overlap
|
|
769
|
+
and segment.overlap_locked
|
|
770
|
+
and min(bottom, segment.end) - max(top, segment.start) > EPSILON
|
|
771
|
+
):
|
|
772
|
+
return True
|
|
773
|
+
for y, horizontal_segments in occupancy.horizontal.items():
|
|
774
|
+
if top + EPSILON < y < bottom - EPSILON:
|
|
775
|
+
for segment in horizontal_segments:
|
|
776
|
+
if segment.start + EPSILON < x < segment.end - EPSILON:
|
|
777
|
+
return True
|
|
778
|
+
return False
|
|
779
|
+
|
|
780
|
+
if start.y == end.y:
|
|
781
|
+
y = round(start.y, 2)
|
|
782
|
+
left, right = sorted((start.x, end.x))
|
|
783
|
+
for segment in occupancy.horizontal.get(y, ()):
|
|
784
|
+
if (
|
|
785
|
+
check_overlap
|
|
786
|
+
and segment.overlap_locked
|
|
787
|
+
and min(right, segment.end) - max(left, segment.start) > EPSILON
|
|
788
|
+
):
|
|
789
|
+
return True
|
|
790
|
+
for x, vertical_segments in occupancy.vertical.items():
|
|
791
|
+
if left + EPSILON < x < right - EPSILON:
|
|
792
|
+
for segment in vertical_segments:
|
|
793
|
+
if segment.start + EPSILON < y < segment.end - EPSILON:
|
|
794
|
+
return True
|
|
795
|
+
return False
|
|
796
|
+
|
|
797
|
+
return True
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _reserve_candidate(occupancy: Occupancy, candidate: CandidatePath, edge_id: str) -> None:
|
|
801
|
+
_reserve_points(
|
|
802
|
+
occupancy,
|
|
803
|
+
candidate.points,
|
|
804
|
+
edge_id,
|
|
805
|
+
overlap_locked_segments=set(candidate.reserved_segments),
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _reserve_points(
|
|
810
|
+
occupancy: Occupancy,
|
|
811
|
+
points: tuple[Point, ...],
|
|
812
|
+
edge_id: str,
|
|
813
|
+
*,
|
|
814
|
+
overlap_locked_segments: set[int] | None = None,
|
|
815
|
+
) -> None:
|
|
816
|
+
overlap_locked_segments = overlap_locked_segments or set(range(len(points) - 1))
|
|
817
|
+
|
|
818
|
+
for segment_index, (start, end) in enumerate(zip(points, points[1:])):
|
|
819
|
+
overlap_locked = segment_index in overlap_locked_segments
|
|
820
|
+
if start.x == end.x:
|
|
821
|
+
top, bottom = sorted((start.y, end.y))
|
|
822
|
+
if bottom - top > EPSILON:
|
|
823
|
+
occupancy.vertical[round(start.x, 2)].append(
|
|
824
|
+
StoredSegment(top, bottom, edge_id, overlap_locked)
|
|
825
|
+
)
|
|
826
|
+
elif start.y == end.y:
|
|
827
|
+
left, right = sorted((start.x, end.x))
|
|
828
|
+
if right - left > EPSILON:
|
|
829
|
+
occupancy.horizontal[round(start.y, 2)].append(
|
|
830
|
+
StoredSegment(left, right, edge_id, overlap_locked)
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _points_conflict(points: tuple[Point, ...], occupancy: Occupancy) -> bool:
|
|
835
|
+
for start, end in zip(points, points[1:]):
|
|
836
|
+
if _segment_conflicts(start, end, occupancy, check_overlap=True):
|
|
837
|
+
return True
|
|
838
|
+
return False
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _select_back_edge_route(
|
|
842
|
+
*,
|
|
843
|
+
source_node: LayoutNode,
|
|
844
|
+
target_node: LayoutNode,
|
|
845
|
+
geometry: ComponentGeometry,
|
|
846
|
+
occupancy: Occupancy,
|
|
847
|
+
base_slot: int,
|
|
848
|
+
pair_offset: float,
|
|
849
|
+
theme: "Theme",
|
|
850
|
+
) -> tuple[Point, ...]:
|
|
851
|
+
best_points: tuple[Point, ...] | None = None
|
|
852
|
+
|
|
853
|
+
for slot in range(base_slot, base_slot + 8):
|
|
854
|
+
points = _route_back_edge(
|
|
855
|
+
source_node=source_node,
|
|
856
|
+
target_node=target_node,
|
|
857
|
+
geometry=geometry,
|
|
858
|
+
slot=slot,
|
|
859
|
+
pair_offset=pair_offset,
|
|
860
|
+
theme=theme,
|
|
861
|
+
)
|
|
862
|
+
best_points = points
|
|
863
|
+
if not _points_conflict(points, occupancy):
|
|
864
|
+
return points
|
|
865
|
+
|
|
866
|
+
assert best_points is not None
|
|
867
|
+
return best_points
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _select_self_loop_route(
|
|
871
|
+
*,
|
|
872
|
+
source_node: LayoutNode,
|
|
873
|
+
geometry: ComponentGeometry,
|
|
874
|
+
occupancy: Occupancy,
|
|
875
|
+
base_slot: int,
|
|
876
|
+
pair_offset: float,
|
|
877
|
+
theme: "Theme",
|
|
878
|
+
) -> tuple[Point, ...]:
|
|
879
|
+
best_points: tuple[Point, ...] | None = None
|
|
880
|
+
|
|
881
|
+
for slot in range(base_slot, base_slot + 8):
|
|
882
|
+
points = _route_self_loop(
|
|
883
|
+
source_node,
|
|
884
|
+
geometry,
|
|
885
|
+
slot,
|
|
886
|
+
pair_offset,
|
|
887
|
+
theme,
|
|
888
|
+
)
|
|
889
|
+
best_points = points
|
|
890
|
+
if not _points_conflict(points, occupancy):
|
|
891
|
+
return points
|
|
892
|
+
|
|
893
|
+
assert best_points is not None
|
|
894
|
+
return best_points
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _route_back_edge(
|
|
898
|
+
*,
|
|
899
|
+
source_node: LayoutNode,
|
|
900
|
+
target_node: LayoutNode,
|
|
901
|
+
geometry: ComponentGeometry,
|
|
902
|
+
slot: int,
|
|
903
|
+
pair_offset: float,
|
|
904
|
+
theme: "Theme",
|
|
905
|
+
) -> tuple[Point, ...]:
|
|
906
|
+
source = _right_port(source_node, pair_offset)
|
|
907
|
+
target = _left_port(target_node, pair_offset)
|
|
908
|
+
lane_y = round(geometry.outer_top - theme.back_edge_gap * slot, 2)
|
|
909
|
+
exit_x = round(source_node.right + theme.route_track_gap * (slot + 1), 2)
|
|
910
|
+
entry_x = round(target_node.x - theme.route_track_gap * (slot + 1), 2)
|
|
911
|
+
return _collapse_points(
|
|
912
|
+
(
|
|
913
|
+
source,
|
|
914
|
+
Point(exit_x, source.y),
|
|
915
|
+
Point(exit_x, lane_y),
|
|
916
|
+
Point(entry_x, lane_y),
|
|
917
|
+
Point(entry_x, target.y),
|
|
918
|
+
target,
|
|
919
|
+
)
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _route_self_loop(
|
|
924
|
+
node: LayoutNode,
|
|
925
|
+
geometry: ComponentGeometry,
|
|
926
|
+
slot: int,
|
|
927
|
+
pair_offset: float,
|
|
928
|
+
theme: "Theme",
|
|
929
|
+
) -> tuple[Point, ...]:
|
|
930
|
+
source = _right_port(node, pair_offset)
|
|
931
|
+
target = _left_port(node, pair_offset)
|
|
932
|
+
loop_x = round(node.right + theme.route_track_gap * (slot + 1), 2)
|
|
933
|
+
lane_y = round(geometry.outer_top - theme.route_track_gap * (slot + 1), 2)
|
|
934
|
+
return _collapse_points(
|
|
935
|
+
(
|
|
936
|
+
source,
|
|
937
|
+
Point(loop_x, source.y),
|
|
938
|
+
Point(loop_x, lane_y),
|
|
939
|
+
Point(node.x - theme.route_track_gap, lane_y),
|
|
940
|
+
Point(node.x - theme.route_track_gap, target.y),
|
|
941
|
+
target,
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _centered_offsets(count: int, gap: float) -> list[float]:
|
|
947
|
+
if count <= 1 or gap == 0:
|
|
948
|
+
return [0.0] * count
|
|
949
|
+
start = -gap * (count - 1) / 2
|
|
950
|
+
return [round(start + gap * index, 2) for index in range(count)]
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _collapse_points(points: tuple[Point, ...]) -> tuple[Point, ...]:
|
|
954
|
+
collapsed: list[Point] = []
|
|
955
|
+
for point in points:
|
|
956
|
+
if collapsed and point == collapsed[-1]:
|
|
957
|
+
continue
|
|
958
|
+
collapsed.append(point)
|
|
959
|
+
return tuple(collapsed)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _bend_count(points: tuple[Point, ...]) -> int:
|
|
963
|
+
directions: list[str] = []
|
|
964
|
+
for start, end in zip(points, points[1:]):
|
|
965
|
+
if start.x == end.x and start.y != end.y:
|
|
966
|
+
directions.append("v")
|
|
967
|
+
elif start.y == end.y and start.x != end.x:
|
|
968
|
+
directions.append("h")
|
|
969
|
+
|
|
970
|
+
bends = 0
|
|
971
|
+
for previous, current in zip(directions, directions[1:]):
|
|
972
|
+
if previous != current:
|
|
973
|
+
bends += 1
|
|
974
|
+
return bends
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _path_length(points: tuple[Point, ...]) -> float:
|
|
978
|
+
return sum(abs(start.x - end.x) + abs(start.y - end.y) for start, end in zip(points, points[1:]))
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _backwards_distance(points: tuple[Point, ...]) -> float:
|
|
982
|
+
backwards = 0.0
|
|
983
|
+
for start, end in zip(points, points[1:]):
|
|
984
|
+
if end.x < start.x:
|
|
985
|
+
backwards += start.x - end.x
|
|
986
|
+
return backwards
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _count_node_collisions(
|
|
990
|
+
points: tuple[Point, ...],
|
|
991
|
+
nodes: dict[str, LayoutNode],
|
|
992
|
+
ignored_node_ids: set[str],
|
|
993
|
+
theme: "Theme",
|
|
994
|
+
) -> int:
|
|
995
|
+
collisions = 0
|
|
996
|
+
padding = max(theme.stroke_width * 2, 2.0)
|
|
997
|
+
for start, end in zip(points, points[1:]):
|
|
998
|
+
for node_id, node in nodes.items():
|
|
999
|
+
if node_id in ignored_node_ids:
|
|
1000
|
+
continue
|
|
1001
|
+
if _segment_hits_bounds(start, end, node.bounds, padding):
|
|
1002
|
+
collisions += 1
|
|
1003
|
+
return collisions
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _segment_hits_bounds(start: Point, end: Point, bounds: Bounds, padding: float) -> bool:
|
|
1007
|
+
left = bounds.x - padding
|
|
1008
|
+
right = bounds.right + padding
|
|
1009
|
+
top = bounds.y - padding
|
|
1010
|
+
bottom = bounds.bottom + padding
|
|
1011
|
+
|
|
1012
|
+
if start.x == end.x:
|
|
1013
|
+
x = start.x
|
|
1014
|
+
segment_top, segment_bottom = sorted((start.y, end.y))
|
|
1015
|
+
return left < x < right and segment_bottom > top and segment_top < bottom
|
|
1016
|
+
|
|
1017
|
+
if start.y == end.y:
|
|
1018
|
+
y = start.y
|
|
1019
|
+
segment_left, segment_right = sorted((start.x, end.x))
|
|
1020
|
+
return top < y < bottom and segment_right > left and segment_left < right
|
|
1021
|
+
|
|
1022
|
+
return False
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _bounds_for_points(points: tuple[Point, ...], stroke_width: float, arrow_size: float) -> Bounds:
|
|
1026
|
+
padding = max(stroke_width, arrow_size)
|
|
1027
|
+
min_x = min(point.x for point in points) - padding
|
|
1028
|
+
min_y = min(point.y for point in points) - padding
|
|
1029
|
+
max_x = max(point.x for point in points) + padding
|
|
1030
|
+
max_y = max(point.y for point in points) + padding
|
|
1031
|
+
return Bounds(x=min_x, y=min_y, width=max_x - min_x, height=max_y - min_y)
|