frameplot 0.5.6__tar.gz → 0.5.7__tar.gz

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.
Files changed (27) hide show
  1. {frameplot-0.5.6/src/frameplot.egg-info → frameplot-0.5.7}/PKG-INFO +1 -1
  2. {frameplot-0.5.6 → frameplot-0.5.7}/pyproject.toml +1 -1
  3. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/route.py +578 -275
  4. {frameplot-0.5.6 → frameplot-0.5.7/src/frameplot.egg-info}/PKG-INFO +1 -1
  5. {frameplot-0.5.6 → frameplot-0.5.7}/tests/test_rendering.py +49 -0
  6. {frameplot-0.5.6 → frameplot-0.5.7}/LICENSE +0 -0
  7. {frameplot-0.5.6 → frameplot-0.5.7}/README.md +0 -0
  8. {frameplot-0.5.6 → frameplot-0.5.7}/setup.cfg +0 -0
  9. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/__init__.py +0 -0
  10. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/api.py +0 -0
  11. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/__init__.py +0 -0
  12. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/order.py +0 -0
  13. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/place.py +0 -0
  14. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/rank.py +0 -0
  15. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/scc.py +0 -0
  16. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/text.py +0 -0
  17. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/types.py +0 -0
  18. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/layout/validate.py +0 -0
  19. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/model.py +0 -0
  20. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/render/__init__.py +0 -0
  21. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/render/png.py +0 -0
  22. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/render/svg.py +0 -0
  23. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot/theme.py +0 -0
  24. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot.egg-info/SOURCES.txt +0 -0
  25. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot.egg-info/dependency_links.txt +0 -0
  26. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot.egg-info/requires.txt +0 -0
  27. {frameplot-0.5.6 → frameplot-0.5.7}/src/frameplot.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frameplot
3
- Version: 0.5.6
3
+ Version: 0.5.7
4
4
  Summary: Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
5
5
  Author: Small Turtle 2
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "frameplot"
7
- version = "0.5.6"
7
+ version = "0.5.7"
8
8
  description = "Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,11 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  from collections import defaultdict
4
4
  from dataclasses import dataclass, field
5
+ from typing import TypeVar
5
6
 
6
7
  from frameplot.layout.types import Bounds, GroupOverlay, LayoutNode, Point, RoutedEdge, union_bounds
7
8
  from frameplot.theme import resolve_theme_metrics
8
9
 
9
10
  EPSILON = 0.01
11
+ SelectionItem = TypeVar("SelectionItem")
10
12
 
11
13
 
12
14
  @dataclass(slots=True)
@@ -133,6 +135,35 @@ class InteractionMetrics:
133
135
  obstacle_crossings: int = 0
134
136
 
135
137
 
138
+ @dataclass(slots=True, frozen=True)
139
+ class CandidateEvaluation:
140
+ clearance_ok: bool
141
+ shared_local: bool
142
+ endpoint_preferred: bool
143
+ clean_direct: bool
144
+ collisions: int
145
+ edge_crossings: int
146
+ edge_overlap_length: float
147
+ kind_priority: int
148
+ backwards: float
149
+ bends: int
150
+ length: float
151
+ candidate_index: int
152
+ candidate: CandidatePath
153
+
154
+
155
+ @dataclass(slots=True, frozen=True)
156
+ class PointRouteEvaluation:
157
+ clearance_ok: bool
158
+ shared_local: bool
159
+ edge_crossings: int
160
+ edge_overlap_length: float
161
+ bends: int
162
+ length: float
163
+ slot: int
164
+ points: tuple[Point, ...]
165
+
166
+
136
167
  def _resolved_target(validated: "ValidatedPipeline | ValidatedDetailPanel", edge: "Edge"):
137
168
  return validated.edge_targets[edge.id]
138
169
 
@@ -803,24 +834,12 @@ def _select_forward_route(
803
834
  target_node=target_node,
804
835
  geometry=geometry,
805
836
  pair_offset=pair_offset,
837
+ routing_groups=routing_groups,
806
838
  theme=theme,
807
839
  )
808
- relevant_groups = _relevant_group_bounds_for_optional_routing_groups(
809
- source_node.node.id,
810
- target_node.node.id,
811
- routing_groups,
812
- )
813
- evaluations: list[
814
- tuple[bool, tuple[int, int, int, int, float, float, int, float, int, float, int], CandidatePath]
815
- ] = []
840
+ evaluations: list[CandidateEvaluation] = []
816
841
 
817
842
  for candidate_index, (kind, candidate) in enumerate(candidates):
818
- clearance_ok = _path_respects_group_clearance(
819
- candidate.points,
820
- source_node.node.id,
821
- target_node.node.id,
822
- routing_groups,
823
- )
824
843
  interactions = _candidate_interaction_metrics(
825
844
  candidate,
826
845
  occupancy=occupancy,
@@ -828,24 +847,86 @@ def _select_forward_route(
828
847
  target_node_id=target_node.node.id,
829
848
  routing_groups=routing_groups,
830
849
  )
831
- priority_key = _forward_priority_key(
832
- kind=kind,
833
- candidate=candidate,
834
- candidate_index=candidate_index,
850
+ evaluations.append(
851
+ _build_forward_candidate_evaluation(
852
+ kind=kind,
853
+ candidate=candidate,
854
+ candidate_index=candidate_index,
855
+ source_node=source_node,
856
+ target_node=target_node,
857
+ nodes=nodes,
858
+ outgoing_count=outgoing_count,
859
+ incoming_count=incoming_count,
860
+ theme=theme,
861
+ interactions=interactions,
862
+ routing_groups=routing_groups,
863
+ )
864
+ )
865
+
866
+ selected = _select_candidate_evaluation(evaluations)
867
+ assert selected is not None
868
+ return selected.candidate
869
+
870
+
871
+ def _build_forward_candidate_evaluation(
872
+ *,
873
+ kind: str,
874
+ candidate: CandidatePath,
875
+ candidate_index: int,
876
+ source_node: LayoutNode,
877
+ target_node: LayoutNode,
878
+ nodes: dict[str, LayoutNode],
879
+ outgoing_count: int,
880
+ incoming_count: int,
881
+ theme: "Theme",
882
+ interactions: InteractionMetrics,
883
+ routing_groups: RoutingGroups | None,
884
+ ) -> CandidateEvaluation:
885
+ clearance_ok = _path_respects_group_clearance(
886
+ candidate.points,
887
+ source_node.node.id,
888
+ target_node.node.id,
889
+ routing_groups,
890
+ )
891
+ collisions, backwards, bends, length = _route_metrics(
892
+ candidate,
893
+ nodes=nodes,
894
+ ignored_node_ids={source_node.node.id, target_node.node.id},
895
+ theme=theme,
896
+ )
897
+ return CandidateEvaluation(
898
+ clearance_ok=clearance_ok,
899
+ shared_local=_candidate_uses_shared_local(kind),
900
+ endpoint_preferred=_forward_endpoint_preferred(
901
+ candidate,
835
902
  source_node=source_node,
836
903
  target_node=target_node,
837
- nodes=nodes,
838
904
  outgoing_count=outgoing_count,
839
905
  incoming_count=incoming_count,
840
- theme=theme,
841
- has_relevant_groups=bool(relevant_groups),
906
+ routing_groups=routing_groups,
907
+ ),
908
+ clean_direct=_forward_clean_direct_preferred(
909
+ kind,
910
+ candidate,
911
+ source_node=source_node,
912
+ target_node=target_node,
913
+ collisions=collisions,
842
914
  interactions=interactions,
843
- )
844
- evaluations.append((clearance_ok, priority_key, candidate))
845
-
846
- selected = _select_candidate_with_priority(evaluations)
847
- assert selected is not None
848
- return selected
915
+ ),
916
+ collisions=collisions,
917
+ edge_crossings=interactions.edge_crossings,
918
+ edge_overlap_length=interactions.edge_overlap_length,
919
+ kind_priority=_forward_kind_priority(
920
+ kind=kind,
921
+ outgoing_count=outgoing_count,
922
+ incoming_count=incoming_count,
923
+ ),
924
+ backwards=backwards,
925
+ bends=bends,
926
+ length=length,
927
+ candidate_index=candidate_index,
928
+ candidate=candidate,
929
+ )
849
930
 
850
931
 
851
932
  def _route_forward(
@@ -1051,18 +1132,13 @@ def _select_edge_join_route(
1051
1132
  join_plan=join_plan,
1052
1133
  geometry=geometry,
1053
1134
  pair_offset=pair_offset,
1135
+ routing_groups=routing_groups,
1054
1136
  theme=theme,
1055
1137
  )
1056
1138
  direction = _edge_direction_kind(source_node, target_node)
1057
- evaluations: list[tuple[bool, tuple[int, int, int, float, float, int, float, int, float, int], CandidatePath]] = []
1139
+ evaluations: list[CandidateEvaluation] = []
1058
1140
 
1059
1141
  for candidate_index, (kind, candidate) in enumerate(candidates):
1060
- clearance_ok = _path_respects_group_clearance(
1061
- candidate.points,
1062
- source_node.node.id,
1063
- target_node.node.id,
1064
- routing_groups,
1065
- )
1066
1142
  interactions = _candidate_interaction_metrics(
1067
1143
  candidate,
1068
1144
  occupancy=occupancy,
@@ -1070,25 +1146,72 @@ def _select_edge_join_route(
1070
1146
  target_node_id=target_node.node.id,
1071
1147
  routing_groups=routing_groups,
1072
1148
  )
1073
- priority_key = _edge_join_priority_key(
1074
- kind=kind,
1075
- candidate=candidate,
1076
- candidate_index=candidate_index,
1077
- source_node=source_node,
1078
- target_node=target_node,
1079
- nodes=nodes,
1080
- theme=theme,
1081
- direction=direction,
1082
- interactions=interactions,
1149
+ evaluations.append(
1150
+ _build_edge_join_candidate_evaluation(
1151
+ kind=kind,
1152
+ candidate=candidate,
1153
+ candidate_index=candidate_index,
1154
+ source_node=source_node,
1155
+ target_node=target_node,
1156
+ nodes=nodes,
1157
+ theme=theme,
1158
+ direction=direction,
1159
+ interactions=interactions,
1160
+ routing_groups=routing_groups,
1161
+ )
1083
1162
  )
1084
- evaluations.append((clearance_ok, priority_key, candidate))
1085
1163
 
1086
- selected = _select_candidate_with_priority(evaluations)
1164
+ selected = _select_candidate_evaluation(evaluations)
1087
1165
  if selected is not None:
1088
- return selected
1166
+ return selected.candidate
1089
1167
  raise AssertionError(f"No viable join route found for edge {edge.id}.")
1090
1168
 
1091
1169
 
1170
+ def _build_edge_join_candidate_evaluation(
1171
+ *,
1172
+ kind: str,
1173
+ candidate: CandidatePath,
1174
+ candidate_index: int,
1175
+ source_node: LayoutNode,
1176
+ target_node: LayoutNode,
1177
+ nodes: dict[str, LayoutNode],
1178
+ theme: "Theme",
1179
+ direction: str,
1180
+ interactions: InteractionMetrics,
1181
+ routing_groups: RoutingGroups | None,
1182
+ ) -> CandidateEvaluation:
1183
+ clearance_ok = _path_respects_group_clearance(
1184
+ candidate.points,
1185
+ source_node.node.id,
1186
+ target_node.node.id,
1187
+ routing_groups,
1188
+ )
1189
+ collisions, backwards, bends, length = _route_metrics(
1190
+ candidate,
1191
+ nodes=nodes,
1192
+ ignored_node_ids={source_node.node.id, target_node.node.id},
1193
+ theme=theme,
1194
+ )
1195
+ return CandidateEvaluation(
1196
+ clearance_ok=clearance_ok,
1197
+ shared_local=_candidate_uses_shared_local(kind),
1198
+ endpoint_preferred=True,
1199
+ clean_direct=False,
1200
+ collisions=collisions,
1201
+ edge_crossings=interactions.edge_crossings,
1202
+ edge_overlap_length=interactions.edge_overlap_length,
1203
+ kind_priority=_edge_join_kind_priority(
1204
+ kind=kind,
1205
+ direction=direction,
1206
+ ),
1207
+ backwards=backwards,
1208
+ bends=bends,
1209
+ length=length,
1210
+ candidate_index=candidate_index,
1211
+ candidate=candidate,
1212
+ )
1213
+
1214
+
1092
1215
  def _build_edge_join_candidates(
1093
1216
  *,
1094
1217
  source_node: LayoutNode,
@@ -1096,19 +1219,32 @@ def _build_edge_join_candidates(
1096
1219
  join_plan: EdgeJoinPlan,
1097
1220
  geometry: ComponentGeometry,
1098
1221
  pair_offset: float,
1222
+ routing_groups: RoutingGroups | None,
1099
1223
  theme: "Theme",
1100
1224
  ) -> list[tuple[str, CandidatePath]]:
1101
1225
  join_point = join_plan.join_point
1102
1226
  source_right = _right_port(source_node, pair_offset)
1227
+ shared_row_lanes = _shared_group_row_lane_positions(
1228
+ source_node.node.id,
1229
+ target_node.node.id,
1230
+ routing_groups,
1231
+ theme,
1232
+ )
1233
+ shared_column_lanes = _shared_group_column_lane_positions(
1234
+ source_node,
1235
+ target_node,
1236
+ geometry,
1237
+ routing_groups,
1238
+ theme,
1239
+ )
1103
1240
  outer_row_lanes = _outer_row_lane_positions(geometry, theme)
1104
1241
  outer_column_lanes = _outer_column_lane_positions(geometry, theme)
1105
1242
  row_lanes = _unique_lanes(
1106
1243
  _source_row_lane_positions(source_node, target_node, geometry, theme)
1107
1244
  + _target_row_lane_positions(source_node, target_node, geometry, theme)
1108
- + outer_row_lanes
1109
1245
  )
1110
1246
  column_lanes = _unique_lanes(
1111
- _join_column_lane_positions(source_node, target_node, geometry, theme) + outer_column_lanes
1247
+ _join_column_lane_positions(source_node, target_node, geometry, theme)
1112
1248
  )
1113
1249
  candidates: dict[tuple[Point, ...], tuple[str, CandidatePath]] = {}
1114
1250
 
@@ -1125,11 +1261,50 @@ def _build_edge_join_candidates(
1125
1261
  (join_point, "stub"),
1126
1262
  ],
1127
1263
  )
1264
+ for lane_y in shared_row_lanes:
1265
+ source_vertical = _vertical_port(source_node, pair_offset, lane_y)
1266
+ add_candidate(
1267
+ "shared_row_join",
1268
+ source_vertical,
1269
+ [
1270
+ (Point(source_vertical.x, lane_y), "stub"),
1271
+ (Point(join_point.x, lane_y), "reserve"),
1272
+ (join_point, "stub"),
1273
+ ],
1274
+ )
1275
+ add_candidate(
1276
+ "shared_row_join",
1277
+ source_right,
1278
+ [
1279
+ (Point(source_right.x, lane_y), "reserve"),
1280
+ (Point(join_point.x, lane_y), "reserve"),
1281
+ (join_point, "stub"),
1282
+ ],
1283
+ )
1128
1284
  for lane_y in row_lanes:
1129
1285
  source_vertical = _vertical_port(source_node, pair_offset, lane_y)
1130
- kind = "outer_row_join" if lane_y in outer_row_lanes else "row_join"
1131
1286
  add_candidate(
1132
- kind,
1287
+ "row_join",
1288
+ source_vertical,
1289
+ [
1290
+ (Point(source_vertical.x, lane_y), "stub"),
1291
+ (Point(join_point.x, lane_y), "reserve"),
1292
+ (join_point, "stub"),
1293
+ ],
1294
+ )
1295
+ add_candidate(
1296
+ "row_join",
1297
+ source_right,
1298
+ [
1299
+ (Point(source_right.x, lane_y), "reserve"),
1300
+ (Point(join_point.x, lane_y), "reserve"),
1301
+ (join_point, "stub"),
1302
+ ],
1303
+ )
1304
+ for lane_y in outer_row_lanes:
1305
+ source_vertical = _vertical_port(source_node, pair_offset, lane_y)
1306
+ add_candidate(
1307
+ "outer_row_join",
1133
1308
  source_vertical,
1134
1309
  [
1135
1310
  (Point(source_vertical.x, lane_y), "stub"),
@@ -1138,7 +1313,7 @@ def _build_edge_join_candidates(
1138
1313
  ],
1139
1314
  )
1140
1315
  add_candidate(
1141
- kind,
1316
+ "outer_row_join",
1142
1317
  source_right,
1143
1318
  [
1144
1319
  (Point(source_right.x, lane_y), "reserve"),
@@ -1146,11 +1321,34 @@ def _build_edge_join_candidates(
1146
1321
  (join_point, "stub"),
1147
1322
  ],
1148
1323
  )
1324
+ for lane_x in shared_column_lanes:
1325
+ for lane_y in shared_row_lanes:
1326
+ add_candidate(
1327
+ "shared_join",
1328
+ source_right,
1329
+ [
1330
+ (Point(lane_x, source_right.y), "reserve"),
1331
+ (Point(lane_x, lane_y), "reserve"),
1332
+ (Point(join_point.x, lane_y), "reserve"),
1333
+ (join_point, "stub"),
1334
+ ],
1335
+ )
1149
1336
  for lane_x in column_lanes:
1150
1337
  for lane_y in row_lanes:
1151
- outer_kind = "outer_join" if lane_x in outer_column_lanes or lane_y in outer_row_lanes else "lane_join"
1152
1338
  add_candidate(
1153
- outer_kind,
1339
+ "lane_join",
1340
+ source_right,
1341
+ [
1342
+ (Point(lane_x, source_right.y), "reserve"),
1343
+ (Point(lane_x, lane_y), "reserve"),
1344
+ (Point(join_point.x, lane_y), "reserve"),
1345
+ (join_point, "stub"),
1346
+ ],
1347
+ )
1348
+ for lane_x in _unique_lanes(shared_column_lanes + column_lanes + outer_column_lanes):
1349
+ for lane_y in outer_row_lanes:
1350
+ add_candidate(
1351
+ "outer_join",
1154
1352
  source_right,
1155
1353
  [
1156
1354
  (Point(lane_x, source_right.y), "reserve"),
@@ -1168,10 +1366,19 @@ def _build_edge_join_candidates(
1168
1366
  (join_point, "stub"),
1169
1367
  ],
1170
1368
  )
1369
+ for lane_x in shared_column_lanes:
1370
+ add_candidate(
1371
+ "shared_column_join",
1372
+ source_right,
1373
+ [
1374
+ (Point(lane_x, source_right.y), "reserve"),
1375
+ (Point(lane_x, join_point.y), "reserve"),
1376
+ (join_point, "stub"),
1377
+ ],
1378
+ )
1171
1379
  for lane_x in column_lanes:
1172
- kind = "outer_column_join" if lane_x in outer_column_lanes else "column_join"
1173
1380
  add_candidate(
1174
- kind,
1381
+ "column_join",
1175
1382
  source_right,
1176
1383
  [
1177
1384
  (Point(lane_x, source_right.y), "reserve"),
@@ -1179,12 +1386,47 @@ def _build_edge_join_candidates(
1179
1386
  (join_point, "stub"),
1180
1387
  ],
1181
1388
  )
1389
+ for lane_x in outer_column_lanes:
1390
+ add_candidate(
1391
+ "outer_column_join",
1392
+ source_right,
1393
+ [
1394
+ (Point(lane_x, source_right.y), "reserve"),
1395
+ (Point(lane_x, join_point.y), "reserve"),
1396
+ (join_point, "stub"),
1397
+ ],
1398
+ )
1399
+ for lane_y in shared_row_lanes:
1400
+ source_vertical = _vertical_port(source_node, pair_offset, lane_y)
1401
+ for lane_x in shared_column_lanes:
1402
+ add_candidate(
1403
+ "shared_join",
1404
+ source_vertical,
1405
+ [
1406
+ (Point(source_vertical.x, lane_y), "stub"),
1407
+ (Point(lane_x, lane_y), "reserve"),
1408
+ (Point(lane_x, join_point.y), "reserve"),
1409
+ (join_point, "stub"),
1410
+ ],
1411
+ )
1182
1412
  for lane_y in row_lanes:
1183
1413
  source_vertical = _vertical_port(source_node, pair_offset, lane_y)
1184
1414
  for lane_x in column_lanes:
1185
- outer_kind = "outer_join" if lane_x in outer_column_lanes or lane_y in outer_row_lanes else "lane_join"
1186
1415
  add_candidate(
1187
- outer_kind,
1416
+ "lane_join",
1417
+ source_vertical,
1418
+ [
1419
+ (Point(source_vertical.x, lane_y), "stub"),
1420
+ (Point(lane_x, lane_y), "reserve"),
1421
+ (Point(lane_x, join_point.y), "reserve"),
1422
+ (join_point, "stub"),
1423
+ ],
1424
+ )
1425
+ for lane_y in _unique_lanes(shared_row_lanes + row_lanes + outer_row_lanes):
1426
+ source_vertical = _vertical_port(source_node, pair_offset, lane_y)
1427
+ for lane_x in outer_column_lanes:
1428
+ add_candidate(
1429
+ "outer_join",
1188
1430
  source_vertical,
1189
1431
  [
1190
1432
  (Point(source_vertical.x, lane_y), "stub"),
@@ -1221,15 +1463,26 @@ def _build_forward_candidates(
1221
1463
  target_node: LayoutNode,
1222
1464
  geometry: ComponentGeometry,
1223
1465
  pair_offset: float,
1466
+ routing_groups: RoutingGroups | None,
1224
1467
  theme: "Theme",
1225
1468
  ) -> list[tuple[str, CandidatePath]]:
1226
1469
  candidates: list[tuple[str, CandidatePath]] = []
1470
+ shared_row_lanes = _shared_group_row_lane_positions(
1471
+ source_node.node.id,
1472
+ target_node.node.id,
1473
+ routing_groups,
1474
+ theme,
1475
+ )
1227
1476
 
1228
1477
  if source_node.order == target_node.order:
1229
1478
  candidates.append(("direct", _direct_same_row_candidate(source_node, target_node, geometry, pair_offset, theme)))
1230
1479
  else:
1231
1480
  candidates.append(("direct_elbow", _direct_elbow_candidate(source_node, target_node, pair_offset)))
1232
1481
 
1482
+ for lane_y in shared_row_lanes:
1483
+ candidates.append(("shared_row", _row_source_candidate(source_node, target_node, lane_y, pair_offset)))
1484
+ candidates.append(("shared_row", _row_target_candidate(source_node, target_node, lane_y, pair_offset)))
1485
+
1233
1486
  if source_node.rank in geometry.gap_after_rank:
1234
1487
  for lane_x in _ordered_gap_positions(geometry.gap_after_rank[source_node.rank], theme, "start"):
1235
1488
  candidates.append(
@@ -1260,7 +1513,11 @@ def _build_forward_candidates(
1260
1513
  for lane_x in _outer_column_lane_positions(geometry, theme):
1261
1514
  candidates.append(("outer_column", _outer_column_candidate(source_node, target_node, lane_x, pair_offset)))
1262
1515
 
1263
- return candidates
1516
+ unique: dict[tuple[Point, ...], tuple[str, CandidatePath]] = {}
1517
+ for kind, candidate in candidates:
1518
+ unique.setdefault(candidate.points, (kind, candidate))
1519
+
1520
+ return list(unique.values())
1264
1521
 
1265
1522
 
1266
1523
  def _direct_same_row_candidate(
@@ -1476,6 +1733,46 @@ def _target_row_lane_positions(
1476
1733
  return tuple(lanes)
1477
1734
 
1478
1735
 
1736
+ def _shared_group_row_lane_positions(
1737
+ source_node_id: str,
1738
+ target_node_id: str,
1739
+ routing_groups: RoutingGroups | None,
1740
+ theme: "Theme",
1741
+ ) -> tuple[float, ...]:
1742
+ frame = _shared_group_frame(source_node_id, target_node_id, routing_groups)
1743
+ if frame is None:
1744
+ return ()
1745
+
1746
+ lanes = tuple(_shared_group_back_edge_lane_y(frame, slot, theme) for slot in range(3))
1747
+ return _unique_lanes(lanes)
1748
+
1749
+
1750
+ def _shared_group_column_lane_positions(
1751
+ source_node: LayoutNode,
1752
+ target_node: LayoutNode,
1753
+ geometry: ComponentGeometry,
1754
+ routing_groups: RoutingGroups | None,
1755
+ theme: "Theme",
1756
+ ) -> tuple[float, ...]:
1757
+ frame = _shared_group_frame(source_node.node.id, target_node.node.id, routing_groups)
1758
+ if frame is None:
1759
+ return ()
1760
+
1761
+ lanes: list[float] = []
1762
+
1763
+ if source_node.rank in geometry.gap_after_rank:
1764
+ clipped_gap = _clip_lane_gap(geometry.gap_after_rank[source_node.rank], frame.bounds.x, frame.bounds.right)
1765
+ if clipped_gap is not None:
1766
+ lanes.extend(_ordered_gap_positions(clipped_gap, theme, "start"))
1767
+
1768
+ if target_node.rank in geometry.gap_before_rank:
1769
+ clipped_gap = _clip_lane_gap(geometry.gap_before_rank[target_node.rank], frame.bounds.x, frame.bounds.right)
1770
+ if clipped_gap is not None:
1771
+ lanes.extend(_ordered_gap_positions(clipped_gap, theme, "end"))
1772
+
1773
+ return _unique_lanes(tuple(lanes))
1774
+
1775
+
1479
1776
  def _outer_row_lane_positions(
1480
1777
  geometry: ComponentGeometry,
1481
1778
  theme: "Theme",
@@ -1516,6 +1813,18 @@ def _ordered_gap_positions(
1516
1813
  )
1517
1814
 
1518
1815
 
1816
+ def _clip_lane_gap(
1817
+ gap: tuple[float, float],
1818
+ lower_bound: float,
1819
+ upper_bound: float,
1820
+ ) -> tuple[float, float] | None:
1821
+ start = round(max(gap[0], lower_bound), 2)
1822
+ end = round(min(gap[1], upper_bound), 2)
1823
+ if end - start <= EPSILON:
1824
+ return None
1825
+ return (start, end)
1826
+
1827
+
1519
1828
  def _lane_positions(start: float, end: float, step: float) -> tuple[float, ...]:
1520
1829
  width = end - start
1521
1830
  if width <= step:
@@ -1558,32 +1867,129 @@ def _vertical_port(node: LayoutNode, pair_offset: float, lane_y: float) -> Point
1558
1867
  return Point(x, node.y)
1559
1868
 
1560
1869
 
1561
- def _select_candidate_with_priority(
1562
- evaluations: list[
1563
- tuple[
1564
- bool,
1565
- tuple[int, int, int, int, float, float, int, float, int, float, int],
1566
- CandidatePath,
1567
- ]
1568
- ],
1569
- ) -> CandidatePath | None:
1570
- for clearance_required in (True, False):
1571
- scoped = [item for item in evaluations if item[0] is clearance_required]
1572
- if not scoped:
1573
- continue
1574
- return min(scoped, key=lambda item: item[1])[2]
1575
- return None
1870
+ def _select_candidate_evaluation(
1871
+ evaluations: list[CandidateEvaluation],
1872
+ ) -> CandidateEvaluation | None:
1873
+ if not evaluations:
1874
+ return None
1576
1875
 
1876
+ scoped = list(evaluations)
1877
+ scoped = _prefer_true(scoped, key=lambda evaluation: evaluation.clearance_ok)
1878
+ scoped = _prefer_true(scoped, key=lambda evaluation: evaluation.shared_local)
1879
+ scoped = _prefer_true(scoped, key=lambda evaluation: evaluation.clean_direct)
1880
+ scoped = _prefer_true(scoped, key=lambda evaluation: evaluation.endpoint_preferred)
1881
+ scoped = _prefer_zero_int(scoped, key=lambda evaluation: evaluation.collisions)
1882
+ scoped = _prefer_zero_int(scoped, key=lambda evaluation: evaluation.edge_crossings)
1883
+ scoped = _prefer_zero_float(scoped, key=lambda evaluation: evaluation.edge_overlap_length)
1884
+ scoped = _prefer_min_int(scoped, key=lambda evaluation: evaluation.kind_priority)
1885
+ scoped = _prefer_min_float(scoped, key=lambda evaluation: evaluation.backwards)
1886
+ scoped = _prefer_min_int(scoped, key=lambda evaluation: evaluation.bends)
1887
+ scoped = _prefer_min_float(scoped, key=lambda evaluation: evaluation.length)
1888
+ scoped = _prefer_min_int(scoped, key=lambda evaluation: evaluation.candidate_index)
1889
+ return scoped[0]
1890
+
1891
+
1892
+ def _select_point_route_evaluation(
1893
+ evaluations: list[PointRouteEvaluation],
1894
+ ) -> PointRouteEvaluation | None:
1895
+ if not evaluations:
1896
+ return None
1897
+
1898
+ scoped = list(evaluations)
1899
+ scoped = _prefer_true(scoped, key=lambda evaluation: evaluation.clearance_ok)
1900
+ scoped = _prefer_true(scoped, key=lambda evaluation: evaluation.shared_local)
1901
+ scoped = _prefer_zero_int(scoped, key=lambda evaluation: evaluation.edge_crossings)
1902
+ scoped = _prefer_zero_float(scoped, key=lambda evaluation: evaluation.edge_overlap_length)
1903
+ scoped = _prefer_min_int(scoped, key=lambda evaluation: evaluation.bends)
1904
+ scoped = _prefer_min_float(scoped, key=lambda evaluation: evaluation.length)
1905
+ scoped = _prefer_min_int(scoped, key=lambda evaluation: evaluation.slot)
1906
+ return scoped[0]
1907
+
1908
+
1909
+ def _prefer_true(items: list[SelectionItem], *, key) -> list[SelectionItem]:
1910
+ if any(key(item) for item in items):
1911
+ return [item for item in items if key(item)]
1912
+ return items
1913
+
1914
+
1915
+ def _prefer_zero_int(items: list[SelectionItem], *, key) -> list[SelectionItem]:
1916
+ zero_items = [item for item in items if key(item) == 0]
1917
+ if zero_items:
1918
+ return zero_items
1919
+ return _prefer_min_int(items, key=key)
1920
+
1921
+
1922
+ def _prefer_zero_float(items: list[SelectionItem], *, key) -> list[SelectionItem]:
1923
+ zero_items = [item for item in items if key(item) <= EPSILON]
1924
+ if zero_items:
1925
+ return zero_items
1926
+ return _prefer_min_float(items, key=key)
1577
1927
 
1578
- def _select_points_with_priority(
1579
- evaluations: list[tuple[bool, tuple[int, float, float, int, int], tuple[Point, ...]]],
1580
- ) -> tuple[Point, ...] | None:
1581
- for clearance_required in (True, False):
1582
- scoped = [item for item in evaluations if item[0] is clearance_required]
1583
- if not scoped:
1584
- continue
1585
- return min(scoped, key=lambda item: item[1])[2]
1586
- return None
1928
+
1929
+ def _prefer_min_int(items: list[SelectionItem], *, key) -> list[SelectionItem]:
1930
+ best = min(key(item) for item in items)
1931
+ return [item for item in items if key(item) == best]
1932
+
1933
+
1934
+ def _prefer_min_float(items: list[SelectionItem], *, key) -> list[SelectionItem]:
1935
+ best = min(key(item) for item in items)
1936
+ return [item for item in items if key(item) <= best + EPSILON]
1937
+
1938
+
1939
+ def _candidate_uses_shared_local(kind: str) -> bool:
1940
+ return kind.startswith("shared_")
1941
+
1942
+
1943
+ def _forward_endpoint_preferred(
1944
+ candidate: CandidatePath,
1945
+ *,
1946
+ source_node: LayoutNode,
1947
+ target_node: LayoutNode,
1948
+ outgoing_count: int,
1949
+ incoming_count: int,
1950
+ routing_groups: RoutingGroups | None,
1951
+ ) -> bool:
1952
+ if _relevant_group_bounds_for_optional_routing_groups(
1953
+ source_node.node.id,
1954
+ target_node.node.id,
1955
+ routing_groups,
1956
+ ):
1957
+ return True
1958
+
1959
+ fanout_context = outgoing_count > 1 and incoming_count <= 1
1960
+ fanin_context = incoming_count > 1 and outgoing_count <= 1
1961
+ preferred = True
1962
+
1963
+ if fanout_context:
1964
+ preferred = preferred and candidate.points[0].x == source_node.right
1965
+ if fanin_context:
1966
+ preferred = preferred and candidate.points[-1].x == target_node.x
1967
+
1968
+ return preferred
1969
+
1970
+
1971
+ def _forward_clean_direct_preferred(
1972
+ kind: str,
1973
+ candidate: CandidatePath,
1974
+ *,
1975
+ source_node: LayoutNode,
1976
+ target_node: LayoutNode,
1977
+ collisions: int,
1978
+ interactions: InteractionMetrics,
1979
+ ) -> bool:
1980
+ if kind != "direct_elbow":
1981
+ return False
1982
+ if source_node.order >= target_node.order:
1983
+ return False
1984
+ if collisions != 0:
1985
+ return False
1986
+ if interactions.edge_crossings != 0:
1987
+ return False
1988
+ if interactions.edge_overlap_length > EPSILON:
1989
+ return False
1990
+ if _backwards_distance(candidate.points) > EPSILON:
1991
+ return False
1992
+ return _bend_count(candidate.points) == 1
1587
1993
 
1588
1994
 
1589
1995
  def _route_metrics(
@@ -1694,10 +2100,17 @@ def _obstacle_group_bounds(
1694
2100
  source_node_id: str,
1695
2101
  target_node_id: str,
1696
2102
  routing_groups: RoutingGroups | None,
2103
+ *,
2104
+ self_loop: bool = False,
1697
2105
  ) -> tuple[Bounds, ...]:
1698
2106
  if routing_groups is None:
1699
2107
  return ()
1700
- return tuple(routing_groups.bounds_by_id[group_id] for group_id in sorted(routing_groups.bounds_by_id))
2108
+ return _relevant_group_bounds(
2109
+ source_node_id,
2110
+ target_node_id,
2111
+ routing_groups,
2112
+ self_loop=self_loop,
2113
+ )
1701
2114
 
1702
2115
 
1703
2116
  def _segment_border_interactions(
@@ -1737,107 +2150,32 @@ def _segment_border_interactions(
1737
2150
  return 0.0, 1
1738
2151
 
1739
2152
 
1740
- def _forward_priority_key(
1741
- *,
1742
- kind: str,
1743
- candidate: CandidatePath,
1744
- candidate_index: int,
1745
- source_node: LayoutNode,
1746
- target_node: LayoutNode,
1747
- nodes: dict[str, LayoutNode],
1748
- outgoing_count: int,
1749
- incoming_count: int,
1750
- theme: "Theme",
1751
- has_relevant_groups: bool,
1752
- interactions: InteractionMetrics,
1753
- ) -> tuple[int, int, int, int, float, float, int, float, int, float, int]:
1754
- collisions, backwards, bends, length = _route_metrics(
1755
- candidate,
1756
- nodes=nodes,
1757
- ignored_node_ids={source_node.node.id, target_node.node.id},
1758
- theme=theme,
1759
- )
1760
- return (
1761
- interactions.edge_crossings,
1762
- collisions,
1763
- _clean_direct_elbow_priority(
1764
- kind,
1765
- collisions=collisions,
1766
- backwards=backwards,
1767
- bends=bends,
1768
- has_relevant_groups=has_relevant_groups,
1769
- interactions=interactions,
1770
- source_node=source_node,
1771
- target_node=target_node,
1772
- ),
1773
- _forward_side_change_penalty(kind, has_relevant_groups=has_relevant_groups),
1774
- interactions.edge_overlap_length,
1775
- interactions.obstacle_overlap_length,
1776
- interactions.obstacle_crossings,
1777
- _forward_kind_priority(
1778
- kind,
1779
- outgoing_count=outgoing_count,
1780
- incoming_count=incoming_count,
1781
- has_relevant_groups=has_relevant_groups,
1782
- ),
1783
- backwards,
1784
- bends,
1785
- length,
1786
- candidate_index,
1787
- )
1788
-
1789
-
1790
- def _clean_direct_elbow_priority(
1791
- kind: str,
1792
- *,
1793
- collisions: int,
1794
- backwards: float,
1795
- bends: int,
1796
- has_relevant_groups: bool,
1797
- interactions: InteractionMetrics,
1798
- source_node: LayoutNode,
1799
- target_node: LayoutNode,
1800
- ) -> int:
1801
- if kind != "direct_elbow" or has_relevant_groups:
1802
- return 1
1803
- if source_node.order >= target_node.order:
1804
- return 1
1805
- if collisions != 0 or bends != 1 or backwards > EPSILON:
1806
- return 1
1807
- if interactions.edge_crossings != 0 or interactions.edge_overlap_length > EPSILON:
1808
- return 1
1809
- if interactions.obstacle_overlap_length > EPSILON or interactions.obstacle_crossings != 0:
1810
- return 1
1811
- return 0
1812
-
1813
-
1814
2153
  def _forward_kind_priority(
1815
2154
  kind: str,
1816
2155
  *,
1817
2156
  outgoing_count: int,
1818
2157
  incoming_count: int,
1819
- has_relevant_groups: bool,
1820
2158
  ) -> int:
1821
2159
  fanout_context = outgoing_count > 1 and incoming_count <= 1
1822
2160
  fanin_context = incoming_count > 1 and outgoing_count <= 1
1823
2161
 
1824
2162
  if kind == "direct":
1825
2163
  return 0
1826
- if kind == "direct_elbow" and has_relevant_groups:
2164
+ if kind == "shared_row":
1827
2165
  return 1
1828
- if fanout_context and kind == "column_source":
2166
+ if kind == "direct_elbow":
1829
2167
  return 2
1830
- if fanout_context and kind == "row_source":
2168
+ if fanout_context and kind == "column_source":
1831
2169
  return 3
2170
+ if fanout_context and kind == "row_source":
2171
+ return 4
1832
2172
  if fanin_context and kind == "column_target":
1833
- return 2
1834
- if fanin_context and kind == "row_target":
1835
2173
  return 3
1836
- if kind in {"column_source", "column_target"}:
2174
+ if fanin_context and kind == "row_target":
1837
2175
  return 4
1838
- if kind in {"row_source", "row_target"}:
2176
+ if kind in {"column_source", "column_target"}:
1839
2177
  return 5
1840
- if kind == "direct_elbow":
2178
+ if kind in {"row_source", "row_target"}:
1841
2179
  return 6
1842
2180
  if kind == "outer_row":
1843
2181
  return 7
@@ -1846,59 +2184,23 @@ def _forward_kind_priority(
1846
2184
  return 9
1847
2185
 
1848
2186
 
1849
- def _forward_side_change_penalty(kind: str, *, has_relevant_groups: bool) -> int:
1850
- if kind in {"row_source", "row_target", "outer_row"}:
1851
- return 1
1852
- if kind == "direct_elbow" and not has_relevant_groups:
1853
- return 1
1854
- return 0
1855
-
1856
-
1857
- def _edge_join_priority_key(
1858
- *,
1859
- kind: str,
1860
- candidate: CandidatePath,
1861
- candidate_index: int,
1862
- source_node: LayoutNode,
1863
- target_node: LayoutNode,
1864
- nodes: dict[str, LayoutNode],
1865
- theme: "Theme",
1866
- direction: str,
1867
- interactions: InteractionMetrics,
1868
- ) -> tuple[int, int, int, float, float, int, float, int, float, int]:
1869
- collisions, backwards, bends, length = _route_metrics(
1870
- candidate,
1871
- nodes=nodes,
1872
- ignored_node_ids={source_node.node.id, target_node.node.id},
1873
- theme=theme,
1874
- )
1875
- return (
1876
- interactions.edge_crossings,
1877
- collisions,
1878
- _edge_join_kind_priority(kind, direction=direction),
1879
- interactions.edge_overlap_length,
1880
- interactions.obstacle_overlap_length,
1881
- interactions.obstacle_crossings,
1882
- backwards,
1883
- bends,
1884
- length,
1885
- candidate_index,
1886
- )
1887
-
1888
-
1889
2187
  def _edge_join_kind_priority(kind: str, *, direction: str) -> int:
1890
2188
  if kind == "direct_join":
1891
2189
  return 0
1892
- if kind in {"row_join", "column_join"}:
2190
+ if kind in {"shared_row_join", "shared_column_join"}:
1893
2191
  return 1
1894
- if kind == "lane_join":
2192
+ if kind == "shared_join":
1895
2193
  return 2
1896
- if kind in {"outer_row_join", "outer_column_join"}:
2194
+ if kind in {"row_join", "column_join"}:
1897
2195
  return 3
1898
- if kind == "outer_join":
2196
+ if kind == "lane_join":
1899
2197
  return 4
2198
+ if kind in {"outer_row_join", "outer_column_join"}:
2199
+ return 5
2200
+ if kind == "outer_join":
2201
+ return 6
1900
2202
  # Keep residual ordering deterministic if new kinds are added later.
1901
- return 5 if direction == "forward" else 6
2203
+ return 7 if direction == "forward" else 8
1902
2204
 
1903
2205
 
1904
2206
  def _candidate_conflicts(candidate: CandidatePath, occupancy: Occupancy) -> bool:
@@ -2101,33 +2403,26 @@ def _repair_forward_conflicts(
2101
2403
  current_key = (
2102
2404
  current_interactions.edge_crossings,
2103
2405
  current_interactions.edge_overlap_length,
2104
- current_interactions.obstacle_overlap_length,
2105
- current_interactions.obstacle_crossings,
2106
2406
  _path_length(route.points),
2107
2407
  )
2108
- if current_key[:4] == (0, 0.0, 0.0, 0):
2408
+ if current_key[:2] == (0, 0.0):
2109
2409
  continue
2110
2410
 
2111
2411
  current_source_side = _point_side_on_node(route.points[0], source_node)
2112
2412
  current_target_side = _point_side_on_node(route.points[-1], target_node)
2113
2413
  geometry = component_geometry[source_node.component_id]
2114
- candidate_evaluations: list[tuple[bool, tuple[int, float, float, int, float], CandidatePath]] = []
2414
+ candidate_evaluations: list[CandidateEvaluation] = []
2115
2415
 
2116
- for candidate in _same_side_forward_candidates(
2416
+ for kind, candidate in _same_side_forward_candidates(
2117
2417
  source_node=source_node,
2118
2418
  target_node=target_node,
2119
2419
  geometry=geometry,
2120
2420
  pair_offset=pair_offsets.get(edge.id, 0.0),
2121
2421
  current_source_side=current_source_side,
2122
2422
  current_target_side=current_target_side,
2423
+ routing_groups=routing_groups,
2123
2424
  theme=validated.theme,
2124
2425
  ):
2125
- clearance_ok = _path_respects_group_clearance(
2126
- candidate.points,
2127
- source_node.node.id,
2128
- target_node.node.id,
2129
- routing_groups,
2130
- )
2131
2426
  interactions = _candidate_interaction_metrics(
2132
2427
  candidate,
2133
2428
  occupancy=occupancy,
@@ -2135,30 +2430,37 @@ def _repair_forward_conflicts(
2135
2430
  target_node_id=target_node.node.id,
2136
2431
  routing_groups=routing_groups,
2137
2432
  )
2138
- candidate_key = (
2139
- interactions.edge_crossings,
2140
- interactions.edge_overlap_length,
2141
- interactions.obstacle_overlap_length,
2142
- interactions.obstacle_crossings,
2143
- _path_length(candidate.points),
2433
+ candidate_evaluations.append(
2434
+ _build_forward_candidate_evaluation(
2435
+ kind=kind,
2436
+ candidate=candidate,
2437
+ candidate_index=len(candidate_evaluations),
2438
+ source_node=source_node,
2439
+ target_node=target_node,
2440
+ nodes=nodes,
2441
+ outgoing_count=0,
2442
+ incoming_count=0,
2443
+ theme=validated.theme,
2444
+ interactions=interactions,
2445
+ routing_groups=routing_groups,
2446
+ )
2144
2447
  )
2145
- candidate_evaluations.append((clearance_ok, candidate_key, candidate))
2146
2448
 
2147
- replacement = _select_repair_candidate(candidate_evaluations)
2449
+ replacement = _select_candidate_evaluation(candidate_evaluations)
2148
2450
  if replacement is None:
2149
2451
  continue
2150
2452
 
2151
- replacement_key = next(
2152
- key
2153
- for clearance_ok, key, candidate in candidate_evaluations
2154
- if candidate.points == replacement.points
2453
+ replacement_key = (
2454
+ replacement.edge_crossings,
2455
+ replacement.edge_overlap_length,
2456
+ replacement.length,
2155
2457
  )
2156
2458
  if replacement_key >= current_key:
2157
2459
  continue
2158
2460
 
2159
- route.points = replacement.points
2461
+ route.points = replacement.candidate.points
2160
2462
  route.bounds = _bounds_for_points(
2161
- replacement.points,
2463
+ replacement.candidate.points,
2162
2464
  validated.theme.stroke_width,
2163
2465
  validated.theme.arrow_size,
2164
2466
  badge_radius=route.join_badge_radius,
@@ -2177,40 +2479,31 @@ def _same_side_forward_candidates(
2177
2479
  pair_offset: float,
2178
2480
  current_source_side: str,
2179
2481
  current_target_side: str,
2482
+ routing_groups: RoutingGroups | None,
2180
2483
  theme: "Theme",
2181
- ) -> tuple[CandidatePath, ...]:
2182
- candidates: list[CandidatePath] = []
2484
+ ) -> tuple[tuple[str, CandidatePath], ...]:
2485
+ candidates: list[tuple[str, CandidatePath]] = []
2183
2486
 
2184
- for _, candidate in _build_forward_candidates(
2487
+ for kind, candidate in _build_forward_candidates(
2185
2488
  source_node=source_node,
2186
2489
  target_node=target_node,
2187
2490
  geometry=geometry,
2188
2491
  pair_offset=pair_offset,
2492
+ routing_groups=routing_groups,
2189
2493
  theme=theme,
2190
2494
  ):
2191
2495
  if _point_side_on_node(candidate.points[0], source_node) != current_source_side:
2192
2496
  continue
2193
2497
  if _point_side_on_node(candidate.points[-1], target_node) != current_target_side:
2194
2498
  continue
2195
- candidates.append(candidate)
2499
+ candidates.append((kind, candidate))
2196
2500
 
2197
- unique: dict[tuple[Point, ...], CandidatePath] = {}
2198
- for candidate in candidates:
2199
- unique.setdefault(candidate.points, candidate)
2501
+ unique: dict[tuple[Point, ...], tuple[str, CandidatePath]] = {}
2502
+ for kind, candidate in candidates:
2503
+ unique.setdefault(candidate.points, (kind, candidate))
2200
2504
  return tuple(unique.values())
2201
2505
 
2202
2506
 
2203
- def _select_repair_candidate(
2204
- evaluations: list[tuple[bool, tuple[int, float, float, int, float], CandidatePath]],
2205
- ) -> CandidatePath | None:
2206
- for clearance_required in (True, False):
2207
- scoped = [item for item in evaluations if item[0] is clearance_required]
2208
- if not scoped:
2209
- continue
2210
- return min(scoped, key=lambda item: item[1])[2]
2211
- return None
2212
-
2213
-
2214
2507
  def _separate_overlapping_endpoints(
2215
2508
  validated: "ValidatedPipeline | ValidatedDetailPanel",
2216
2509
  nodes: dict[str, LayoutNode],
@@ -2891,7 +3184,8 @@ def _select_back_edge_route(
2891
3184
  routing_groups: RoutingGroups | None,
2892
3185
  theme: "Theme",
2893
3186
  ) -> tuple[Point, ...]:
2894
- evaluations: list[tuple[bool, tuple[int, float, float, int, int], tuple[Point, ...]]] = []
3187
+ evaluations: list[PointRouteEvaluation] = []
3188
+ shared_local = _shared_group_frame(source_node.node.id, target_node.node.id, routing_groups) is not None
2895
3189
 
2896
3190
  for slot in range(base_slot, base_slot + 8):
2897
3191
  points = _route_back_edge(
@@ -2916,18 +3210,22 @@ def _select_back_edge_route(
2916
3210
  target_node_id=target_node.node.id,
2917
3211
  routing_groups=routing_groups,
2918
3212
  )
2919
- priority_key = (
2920
- interactions.edge_crossings,
2921
- interactions.edge_overlap_length,
2922
- interactions.obstacle_overlap_length,
2923
- interactions.obstacle_crossings,
2924
- slot,
3213
+ evaluations.append(
3214
+ PointRouteEvaluation(
3215
+ clearance_ok=clearance_ok,
3216
+ shared_local=shared_local,
3217
+ edge_crossings=interactions.edge_crossings,
3218
+ edge_overlap_length=interactions.edge_overlap_length,
3219
+ bends=_bend_count(points),
3220
+ length=_path_length(points),
3221
+ slot=slot,
3222
+ points=points,
3223
+ )
2925
3224
  )
2926
- evaluations.append((clearance_ok, priority_key, points))
2927
3225
 
2928
- selected = _select_points_with_priority(evaluations)
3226
+ selected = _select_point_route_evaluation(evaluations)
2929
3227
  if selected is not None:
2930
- return selected
3228
+ return selected.points
2931
3229
  raise AssertionError("No back-edge route found.")
2932
3230
 
2933
3231
 
@@ -2941,7 +3239,8 @@ def _select_self_loop_route(
2941
3239
  routing_groups: RoutingGroups | None,
2942
3240
  theme: "Theme",
2943
3241
  ) -> tuple[Point, ...]:
2944
- evaluations: list[tuple[bool, tuple[int, float, float, int, int], tuple[Point, ...]]] = []
3242
+ evaluations: list[PointRouteEvaluation] = []
3243
+ shared_local = _shared_group_frame(source_node.node.id, source_node.node.id, routing_groups) is not None
2945
3244
 
2946
3245
  for slot in range(base_slot, base_slot + 8):
2947
3246
  points = _route_self_loop(
@@ -2966,18 +3265,22 @@ def _select_self_loop_route(
2966
3265
  target_node_id=source_node.node.id,
2967
3266
  routing_groups=routing_groups,
2968
3267
  )
2969
- priority_key = (
2970
- interactions.edge_crossings,
2971
- interactions.edge_overlap_length,
2972
- interactions.obstacle_overlap_length,
2973
- interactions.obstacle_crossings,
2974
- slot,
3268
+ evaluations.append(
3269
+ PointRouteEvaluation(
3270
+ clearance_ok=clearance_ok,
3271
+ shared_local=shared_local,
3272
+ edge_crossings=interactions.edge_crossings,
3273
+ edge_overlap_length=interactions.edge_overlap_length,
3274
+ bends=_bend_count(points),
3275
+ length=_path_length(points),
3276
+ slot=slot,
3277
+ points=points,
3278
+ )
2975
3279
  )
2976
- evaluations.append((clearance_ok, priority_key, points))
2977
3280
 
2978
- selected = _select_points_with_priority(evaluations)
3281
+ selected = _select_point_route_evaluation(evaluations)
2979
3282
  if selected is not None:
2980
- return selected
3283
+ return selected.points
2981
3284
 
2982
3285
  raise AssertionError("No self-loop route found.")
2983
3286
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frameplot
3
- Version: 0.5.6
3
+ Version: 0.5.7
4
4
  Summary: Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
5
5
  Author: Small Turtle 2
6
6
  License-Expression: MIT
@@ -176,6 +176,34 @@ def _build_generate_pipeline_fixture() -> Pipeline:
176
176
  )
177
177
 
178
178
 
179
+ def _build_shared_group_join_fixture() -> Pipeline:
180
+ theme = Theme.research()
181
+
182
+ return Pipeline(
183
+ nodes=(
184
+ Node("feature", "Feature", width=220.0),
185
+ Node("gamma", "γ", fill="#E8F5E9", width=180.0),
186
+ Node("beta", "β", fill="#E8F5E9", width=180.0),
187
+ Node("output", "Output", width=180.0),
188
+ Node("density", "Cloud Density Estimation", fill="#FDECEC", width=220.0),
189
+ ),
190
+ edges=(
191
+ Edge("e_main", "feature", "output"),
192
+ Edge("e_gamma", "gamma", "e_main", merge_symbol="x"),
193
+ Edge("e_beta", "beta", "e_main", merge_symbol="+"),
194
+ Edge("e_density_gamma", "density", "gamma", color="#DD8899", dashed=True),
195
+ Edge("e_density_beta", "density", "beta", color="#DD8899", dashed=True),
196
+ ),
197
+ groups=(
198
+ Group("g_outer", "Density Modulation", ("feature", "gamma", "beta", "density", "output")),
199
+ Group("g_adain", "AdaIN", ("feature", "gamma", "beta", "output")),
200
+ Group("g_scale", "Scale / Shift", ("gamma", "beta")),
201
+ Group("g_density", "Density", ("density",)),
202
+ ),
203
+ theme=theme,
204
+ )
205
+
206
+
179
207
  def _make_layout_node(
180
208
  node_id: str,
181
209
  *,
@@ -923,6 +951,27 @@ def test_generate_pipeline_script_prefers_clean_single_bend_guidance_elbow() ->
923
951
  assert points[-1] == Point(target.center_x, target.y)
924
952
 
925
953
 
954
+ def test_shared_group_join_prefers_local_header_lane_over_global_detour() -> None:
955
+ layout = build_layout(_build_shared_group_join_fixture())
956
+ routed = {edge.edge.id: edge for edge in layout.main.edges}
957
+ overlays = {overlay.group.id: overlay.bounds for overlay in layout.main.groups}
958
+ adain = overlays["g_adain"]
959
+ outer = overlays["g_outer"]
960
+ member_top = min(layout.main.nodes[node_id].y for node_id in ("feature", "gamma", "beta", "output"))
961
+
962
+ main_lane_y = min(point.y for point in routed["e_main"].points)
963
+ assert adain.y < main_lane_y < member_top
964
+ assert outer.y <= main_lane_y
965
+
966
+ gamma_join = routed["e_gamma"]
967
+ beta_join = routed["e_beta"]
968
+ assert gamma_join.join_point is not None
969
+ assert beta_join.join_point is not None
970
+ assert adain.y < gamma_join.join_point.y < member_top
971
+ assert adain.y < beta_join.join_point.y < member_top
972
+ assert max(point.x for point in beta_join.points) <= adain.right + 0.01
973
+
974
+
926
975
  def test_back_edge_leaves_group_before_bending() -> None:
927
976
  pipeline = Pipeline(
928
977
  nodes=[
File without changes
File without changes
File without changes